diff --git a/ChangeLog b/ChangeLog index 827fd51..7206e72 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,6 +1,18 @@ CHANGES ======= +v4.2.3 +------ + +* Fixed a problem with send-message command + +v2.4.2 +------ + +* Updated Changelog +* Be more careful picking data to/from disk +* Updated Changelog + v2.4.1 ------ @@ -38,11 +50,11 @@ v2.4.0 * Refactored client classes * Refactor utils usage * 2.3.1 Changelog -* Fixed issue of aprs-is missing keepalive v2.3.1 ------ +* Fixed issue of aprs-is missing keepalive * Fixed packet processing issue with aprsd send-message v2.3.0 diff --git a/aprsd/aprsd.py b/aprsd/aprsd.py new file mode 100644 index 0000000..1556a01 --- /dev/null +++ b/aprsd/aprsd.py @@ -0,0 +1,126 @@ +# +# Listen on amateur radio aprs-is network for messages and respond to them. +# You must have an amateur radio callsign to use this software. You must +# create an ~/.aprsd/config.yml file with all of the required settings. To +# generate an example config.yml, just run aprsd, then copy the sample config +# to ~/.aprsd/config.yml and edit the settings. +# +# APRS messages: +# l(ocation) = descriptive location of calling station +# w(eather) = temp, (hi/low) forecast, later forecast +# t(ime) = respond with the current time +# f(ortune) = respond with a short fortune +# -email_addr email text = send an email +# -2 = display the last 2 emails received +# p(ing) = respond with Pong!/time +# anything else = respond with usage +# +# (C)2018 Craig Lamparter +# License GPLv2 +# + +# python included libs +import datetime +import logging +import os +import signal +import sys +import time + +import click +import click_completion + +# local imports here +import aprsd +from aprsd import cli_helper +from aprsd import config as aprsd_config +from aprsd import messaging, packets, stats, threads, utils + + +# setup the global logger +# logging.basicConfig(level=logging.DEBUG) # level=10 +LOG = logging.getLogger("APRSD") +CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) +flask_enabled = False + + +def custom_startswith(string, incomplete): + """A custom completion match that supports case insensitive matching.""" + if os.environ.get("_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE"): + string = string.lower() + incomplete = incomplete.lower() + return string.startswith(incomplete) + + +click_completion.core.startswith = custom_startswith +click_completion.init() + + +@click.group(context_settings=CONTEXT_SETTINGS) +@click.version_option() +@click.pass_context +def cli(ctx): + pass + + +def main(): + # First import all the possible commands for the CLI + # The commands themselves live in the cmds directory + from .cmds import ( # noqa + completion, dev, healthcheck, listen, send_message, server, + ) + cli() + + +def signal_handler(sig, frame): + global flask_enabled + + click.echo("signal_handler: called") + threads.APRSDThreadList().stop_all() + if "subprocess" not in str(frame): + LOG.info( + "Ctrl+C, Sending all threads exit! Can take up to 10 seconds {}".format( + datetime.datetime.now(), + ), + ) + time.sleep(1.5) + messaging.MsgTrack().save() + packets.WatchList().save() + packets.SeenList().save() + LOG.info(stats.APRSDStats()) + # signal.signal(signal.SIGTERM, sys.exit(0)) + # sys.exit(0) + if flask_enabled: + signal.signal(signal.SIGTERM, sys.exit(0)) + + +@cli.command() +@cli_helper.add_options(cli_helper.common_options) +@click.pass_context +@cli_helper.process_standard_options +def check_version(ctx): + """Check this version against the latest in pypi.org.""" + level, msg = utils._check_version() + if level: + click.secho(msg, fg="yellow") + else: + click.secho(msg, fg="green") + + +@cli.command() +@click.pass_context +def sample_config(ctx): + """This dumps the config to stdout.""" + click.echo(aprsd_config.dump_default_cfg()) + + +@cli.command() +@click.pass_context +def version(ctx): + """Show the APRSD version.""" + click.echo(click.style("APRSD Version : ", fg="white"), nl=False) + click.secho(f"{aprsd.__version__}", fg="yellow", bold=True) + + +if __name__ == "__main__": + main() diff --git a/aprsd/cli_helper.py b/aprsd/cli_helper.py new file mode 100644 index 0000000..ab8bf7e --- /dev/null +++ b/aprsd/cli_helper.py @@ -0,0 +1,67 @@ +from functools import update_wrapper +import typing as t + +import click + +from aprsd import config as aprsd_config +from aprsd import log + + +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) + +common_options = [ + click.option( + "--loglevel", + default="INFO", + show_default=True, + type=click.Choice( + ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], + case_sensitive=False, + ), + show_choices=True, + help="The log level to use for aprsd.log", + ), + click.option( + "-c", + "--config", + "config_file", + show_default=True, + default=aprsd_config.DEFAULT_CONFIG_FILE, + help="The aprsd config file to use for options.", + ), + click.option( + "--quiet", + is_flag=True, + default=False, + help="Don't log to stdout", + ), +] + + +def add_options(options): + def _add_options(func): + for option in reversed(options): + func = option(func) + return func + return _add_options + + +def process_standard_options(f: F) -> F: + def new_func(*args, **kwargs): + ctx = args[0] + ctx.ensure_object(dict) + ctx.obj["loglevel"] = kwargs["loglevel"] + ctx.obj["config_file"] = kwargs["config_file"] + ctx.obj["quiet"] = kwargs["quiet"] + ctx.obj["config"] = aprsd_config.parse_config(kwargs["config_file"]) + log.setup_logging( + ctx.obj["config"], ctx.obj["loglevel"], + ctx.obj["quiet"], + ) + + del kwargs["loglevel"] + del kwargs["config_file"] + del kwargs["quiet"] + return f(*args, **kwargs) + + return update_wrapper(t.cast(F, new_func), f) diff --git a/aprsd/clients/__init__.py b/aprsd/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aprsd/cmds/__init__.py b/aprsd/cmds/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aprsd/cmds/completion.py b/aprsd/cmds/completion.py new file mode 100644 index 0000000..274443b --- /dev/null +++ b/aprsd/cmds/completion.py @@ -0,0 +1,36 @@ +import click +import click_completion + +from ..aprsd import cli + + +CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) + + +@cli.group(help="Click Completion subcommands", context_settings=CONTEXT_SETTINGS) +@click.pass_context +def completion(ctx): + pass + + +# show dumps out the completion code for a particular shell +@completion.command(help="Show completion code for shell", name="show") +@click.option("-i", "--case-insensitive/--no-case-insensitive", help="Case insensitive completion") +@click.argument("shell", required=False, type=click_completion.DocumentedChoice(click_completion.core.shells)) +def show(shell, case_insensitive): + """Show the click-completion-command completion code""" + extra_env = {"_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE": "ON"} if case_insensitive else {} + click.echo(click_completion.core.get_code(shell, extra_env=extra_env)) + + +# install will install the completion code for a particular shell +@completion.command(help="Install completion code for a shell", name="install") +@click.option("--append/--overwrite", help="Append the completion code to the file", default=None) +@click.option("-i", "--case-insensitive/--no-case-insensitive", help="Case insensitive completion") +@click.argument("shell", required=False, type=click_completion.DocumentedChoice(click_completion.core.shells)) +@click.argument("path", required=False) +def install(append, case_insensitive, shell, path): + """Install the click-completion-command completion""" + extra_env = {"_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE": "ON"} if case_insensitive else {} + shell, path = click_completion.core.install(shell=shell, path=path, append=append, extra_env=extra_env) + click.echo(f"{shell} completion installed in {path}") diff --git a/aprsd/cmds/dev.py b/aprsd/cmds/dev.py new file mode 100644 index 0000000..8c372d6 --- /dev/null +++ b/aprsd/cmds/dev.py @@ -0,0 +1,118 @@ +# +# Dev.py is used to help develop plugins +# +# +# python included libs +import logging + +import click + +# local imports here +from aprsd import cli_helper, client, plugin + +from ..aprsd import cli + + +LOG = logging.getLogger("APRSD") +CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) + + +@cli.group(help="Development type subcommands", context_settings=CONTEXT_SETTINGS) +@click.pass_context +def dev(ctx): + pass + + +@dev.command() +@cli_helper.add_options(cli_helper.common_options) +@click.option( + "--aprs-login", + envvar="APRS_LOGIN", + show_envvar=True, + help="What callsign to send the message from.", +) +@click.option( + "-p", + "--plugin", + "plugin_path", + show_default=True, + default=None, + help="The plugin to run. Ex: aprsd.plugins.ping.PingPlugin", +) +@click.option( + "-a", + "--all", + "load_all", + show_default=True, + is_flag=True, + default=False, + help="Load all the plugins in config?", +) +@click.option( + "-n", + "--num", + "number", + show_default=True, + default=1, + help="Number of times to call the plugin", +) +@click.argument("message", nargs=-1, required=True) +@click.pass_context +@cli_helper.process_standard_options +def test_plugin( + ctx, + aprs_login, + plugin_path, + load_all, + number, + message, +): + """Test an individual APRSD plugin given a python path.""" + config = ctx.obj["config"] + fromcall = aprs_login + + if not plugin_path: + click.echo(ctx.get_help()) + click.echo("") + ctx.fail("Failed to provide -p option to test a plugin") + ctx.exit() + + if type(message) is tuple: + message = " ".join(message) + client.Client(config) + + pm = plugin.PluginManager(config) + if load_all: + pm.setup_plugins() + else: + pm._init() + obj = pm._create_class(plugin_path, plugin.APRSDPluginBase, config=config) + if not obj: + click.echo(ctx.get_help()) + click.echo("") + ctx.fail(f"Failed to create object from plugin path '{plugin_path}'") + ctx.exit() + + # Register the plugin they wanted tested. + LOG.info( + "Testing plugin {} Version {}".format( + obj.__class__, obj.version, + ), + ) + pm._pluggy_pm.register(obj) + login = config["aprs"]["login"] + + packet = { + "from": fromcall, "addresse": login, + "message_text": message, + "format": "message", + "msgNo": 1, + } + LOG.info(f"P'{plugin_path}' F'{fromcall}' C'{message}'") + + for x in range(number): + reply = pm.run(packet) + # Plugin might have threads, so lets stop them so we can exit. + # obj.stop_threads() + LOG.info(f"Result{x} = '{reply}'") + pm.stop() diff --git a/aprsd/cmds/healthcheck.py b/aprsd/cmds/healthcheck.py new file mode 100644 index 0000000..333dd05 --- /dev/null +++ b/aprsd/cmds/healthcheck.py @@ -0,0 +1,79 @@ +# +# Used to fetch the stats url and determine if +# aprsd server is 'healthy' +# +# +# python included libs +import datetime +import json +import logging +import sys + +import click +import requests + +import aprsd +from aprsd import cli_helper, utils + +# local imports here +from ..aprsd import cli + + +# setup the global logger +# logging.basicConfig(level=logging.DEBUG) # level=10 +LOG = logging.getLogger("APRSD") + + +@cli.command() +@cli_helper.add_options(cli_helper.common_options) +@click.option( + "--url", + "health_url", + show_default=True, + default="http://localhost:8001/stats", + help="The aprsd url to call for checking health/stats", +) +@click.option( + "--timeout", + show_default=True, + default=3, + help="How long to wait for healtcheck url to come back", +) +@click.pass_context +@cli_helper.process_standard_options +def healthcheck(ctx, health_url, timeout): + """Check the health of the running aprsd server.""" + ctx.obj["config"] + LOG.debug(f"APRSD HealthCheck version: {aprsd.__version__}") + + try: + url = health_url + response = requests.get(url, timeout=timeout) + response.raise_for_status() + except Exception as ex: + LOG.error(f"Failed to fetch healthcheck url '{url}' : '{ex}'") + sys.exit(-1) + else: + stats = json.loads(response.text) + LOG.debug(stats) + + email_thread_last_update = stats["stats"]["email"]["thread_last_update"] + + delta = utils.parse_delta_str(email_thread_last_update) + d = datetime.timedelta(**delta) + max_timeout = {"hours": 0.0, "minutes": 5, "seconds": 0} + max_delta = datetime.timedelta(**max_timeout) + if d > max_delta: + LOG.error(f"Email thread is very old! {d}") + sys.exit(-1) + + aprsis_last_update = stats["stats"]["aprs-is"]["last_update"] + delta = utils.parse_delta_str(aprsis_last_update) + d = datetime.timedelta(**delta) + max_timeout = {"hours": 0.0, "minutes": 5, "seconds": 0} + max_delta = datetime.timedelta(**max_timeout) + if d > max_delta: + LOG.error(f"APRS-IS last update is very old! {d}") + sys.exit(-1) + + sys.exit(0) diff --git a/aprsd/cmds/listen.py b/aprsd/cmds/listen.py new file mode 100644 index 0000000..49d424c --- /dev/null +++ b/aprsd/cmds/listen.py @@ -0,0 +1,161 @@ +# +# License GPLv2 +# + +# python included libs +import datetime +import logging +import sys +import time + +import aprslib +import click + +# local imports here +import aprsd +from aprsd import ( + cli_helper, client, messaging, packets, stats, threads, trace, utils, +) + +from ..aprsd import cli + + +# setup the global logger +# logging.basicConfig(level=logging.DEBUG) # level=10 +LOG = logging.getLogger("APRSD") + + +def signal_handler(sig, frame): + threads.APRSDThreadList().stop_all() + if "subprocess" not in str(frame): + LOG.info( + "Ctrl+C, Sending all threads exit! Can take up to 10 seconds {}".format( + datetime.datetime.now(), + ), + ) + time.sleep(5) + LOG.info(stats.APRSDStats()) + + +@cli.command() +@cli_helper.add_options(cli_helper.common_options) +@click.option( + "--aprs-login", + envvar="APRS_LOGIN", + show_envvar=True, + help="What callsign to send the message from.", +) +@click.option( + "--aprs-password", + envvar="APRS_PASSWORD", + show_envvar=True, + help="the APRS-IS password for APRS_LOGIN", +) +@click.argument( + "filter", + nargs=-1, + required=True, +) +@click.pass_context +@cli_helper.process_standard_options +def listen( + ctx, + aprs_login, + aprs_password, + filter, +): + """Listen to packets on the APRS-IS Network based on FILTER. + + FILTER is the APRS Filter to use.\n + see http://www.aprs-is.net/javAPRSFilter.aspx\n + r/lat/lon/dist - Range Filter Pass posits and objects within dist km from lat/lon.\n + p/aa/bb/cc... - Prefix Filter Pass traffic with fromCall that start with aa or bb or cc.\n + b/call1/call2... - Budlist Filter Pass all traffic from exact call: call1, call2, ... (* wild card allowed) \n + o/obj1/obj2... - Object Filter Pass all objects with the exact name of obj1, obj2, ... (* wild card allowed)\n + + """ + config = ctx.obj["config"] + + if not aprs_login: + click.echo(ctx.get_help()) + click.echo("") + ctx.fail("Must set --aprs_login or APRS_LOGIN") + ctx.exit() + + if not aprs_password: + click.echo(ctx.get_help()) + click.echo("") + ctx.fail("Must set --aprs-password or APRS_PASSWORD") + ctx.exit() + + config["aprs"]["login"] = aprs_login + config["aprs"]["password"] = aprs_password + + LOG.info(f"APRSD Listen 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) + + # Try and load saved MsgTrack list + LOG.debug("Loading saved MsgTrack object.") + messaging.MsgTrack(config=config).load() + packets.WatchList(config=config).load() + packets.SeenList(config=config).load() + + @trace.trace + def rx_packet(packet): + resp = packet.get("response", None) + if resp == "ack": + ack_num = packet.get("msgNo") + LOG.info(f"We saw an ACK {ack_num} Ignoring") + messaging.log_packet(packet) + else: + message = packet.get("message_text", None) + fromcall = packet["from"] + msg_number = packet.get("msgNo", "0") + messaging.log_message( + "Received Message", + packet["raw"], + message, + fromcall=fromcall, + ack=msg_number, + ) + + # 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) + + # Creates the client object + LOG.info("Creating client connection") + client.factory.create().client + aprs_client = client.factory.create().client + + LOG.debug(f"Filter by '{filter}'") + aprs_client.set_filter(filter) + + while True: + try: + # This will register a packet consumer with aprslib + # When new packets come in the consumer will process + # the packet + aprs_client.consumer(rx_packet, raw=False) + except aprslib.exceptions.ConnectionDrop: + LOG.error("Connection dropped, reconnecting") + time.sleep(5) + # Force the deletion of the client object connected to aprs + # This will cause a reconnect, next time client.get_client() + # is called + aprs_client.reset() + except aprslib.exceptions.UnknownFormat: + LOG.error("Got a Bad packet") diff --git a/aprsd/cmds/send_message.py b/aprsd/cmds/send_message.py new file mode 100644 index 0000000..d905b24 --- /dev/null +++ b/aprsd/cmds/send_message.py @@ -0,0 +1,165 @@ +import logging +import sys +import time + +import aprslib +from aprslib.exceptions import LoginError +import click + +import aprsd +from aprsd import cli_helper, client, messaging, packets + +from ..aprsd import cli + + +LOG = logging.getLogger("APRSD") + + +@cli.command() +@cli_helper.add_options(cli_helper.common_options) +@click.option( + "--aprs-login", + envvar="APRS_LOGIN", + show_envvar=True, + help="What callsign to send the message from.", +) +@click.option( + "--aprs-password", + envvar="APRS_PASSWORD", + show_envvar=True, + help="the APRS-IS password for APRS_LOGIN", +) +@click.option( + "--no-ack", + "-n", + is_flag=True, + show_default=True, + default=False, + help="Don't wait for an ack, just sent it to APRS-IS and bail.", +) +@click.option( + "--wait-response", + "-w", + is_flag=True, + show_default=True, + default=False, + help="Wait for a response to the message?", +) +@click.option("--raw", default=None, help="Send a raw message. Implies --no-ack") +@click.argument("tocallsign", required=True) +@click.argument("command", nargs=-1, required=True) +@click.pass_context +@cli_helper.process_standard_options +def send_message( + ctx, + aprs_login, + aprs_password, + no_ack, + wait_response, + raw, + tocallsign, + command, +): + """Send a message to a callsign via APRS_IS.""" + global got_ack, got_response + config = ctx.obj["config"] + quiet = ctx.obj["quiet"] + + if not aprs_login: + click.echo("Must set --aprs_login or APRS_LOGIN") + return + + if not aprs_password: + click.echo("Must set --aprs-password or APRS_PASSWORD") + return + + config["aprs"]["login"] = aprs_login + config["aprs"]["password"] = aprs_password + + LOG.info(f"APRSD LISTEN Started version: {aprsd.__version__}") + if type(command) is tuple: + command = " ".join(command) + if not quiet: + if raw: + LOG.info(f"L'{aprs_login}' R'{raw}'") + else: + LOG.info(f"L'{aprs_login}' To'{tocallsign}' C'{command}'") + + packets.PacketList(config=config) + packets.WatchList(config=config) + packets.SeenList(config=config) + + got_ack = False + got_response = False + + def rx_packet(packet): + global got_ack, got_response + # LOG.debug("Got packet back {}".format(packet)) + resp = packet.get("response", None) + if resp == "ack": + ack_num = packet.get("msgNo") + LOG.info(f"We got ack for our sent message {ack_num}") + messaging.log_packet(packet) + got_ack = True + else: + message = packet.get("message_text", None) + fromcall = packet["from"] + msg_number = packet.get("msgNo", "0") + messaging.log_message( + "Received Message", + packet["raw"], + message, + fromcall=fromcall, + ack=msg_number, + ) + got_response = True + # Send the ack back? + ack = messaging.AckMessage( + config["aprs"]["login"], + fromcall, + msg_id=msg_number, + ) + ack.send_direct() + + if got_ack: + if wait_response: + if got_response: + sys.exit(0) + else: + sys.exit(0) + + try: + client.ClientFactory.setup(config) + client.factory.create().client + except LoginError: + sys.exit(-1) + + # Send a message + # then we setup a consumer to rx messages + # We should get an ack back as well as a new message + # we should bail after we get the ack and send an ack back for the + # message + if raw: + msg = messaging.RawMessage(raw) + msg.send_direct() + sys.exit(0) + else: + msg = messaging.TextMessage(aprs_login, tocallsign, command) + msg.send_direct() + + if no_ack: + sys.exit(0) + + try: + # This will register a packet consumer with aprslib + # When new packets come in the consumer will process + # the packet + aprs_client = client.factory.create().client + aprs_client.consumer(rx_packet, raw=False) + except aprslib.exceptions.ConnectionDrop: + LOG.error("Connection dropped, reconnecting") + time.sleep(5) + # Force the deletion of the client object connected to aprs + # This will cause a reconnect, next time client.get_client() + # is called + aprs_client.reset() diff --git a/aprsd/cmds/server.py b/aprsd/cmds/server.py new file mode 100644 index 0000000..48014f9 --- /dev/null +++ b/aprsd/cmds/server.py @@ -0,0 +1,121 @@ +import logging +import signal +import sys + +import click + +import aprsd +from aprsd import ( + cli_helper, client, flask, messaging, packets, plugin, stats, threads, + trace, utils, +) +from aprsd import aprsd as aprsd_main + +from ..aprsd import cli + + +LOG = logging.getLogger("APRSD") + + +# 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.pass_context +@cli_helper.process_standard_options +def server(ctx, flush): + """Start the aprsd server gateway process.""" + 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]}") + + if config["aprsd"].get("trace", False): + trace.setup_tracing(["method", "api"]) + 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) + + # Creates the client object + LOG.info("Creating client connection") + client.factory.create().client + + # Now load the msgTrack from disk if any + packets.PacketList(config=config) + if flush: + LOG.debug("Deleting saved MsgTrack.") + messaging.MsgTrack(config=config).flush() + packets.WatchList(config=config) + packets.SeenList(config=config) + else: + # Try and load saved MsgTrack list + LOG.debug("Loading saved MsgTrack object.") + messaging.MsgTrack(config=config).load() + packets.WatchList(config=config).load() + packets.SeenList(config=config).load() + + # Create the initial PM singleton and Register plugins + LOG.info("Loading Plugin Manager and registering plugins") + plugin_manager = plugin.PluginManager(config) + plugin_manager.setup_plugins() + + rx_thread = threads.APRSDRXThread( + msg_queues=threads.msg_queues, + config=config, + ) + rx_thread.start() + + messaging.MsgTrack().restart() + + keepalive = threads.KeepAliveThread(config=config) + keepalive.start() + + web_enabled = config.get("aprsd.web.enabled", default=False) + + if web_enabled: + aprsd_main.flask_enabled = True + (socketio, app) = flask.init_flask(config, loglevel, quiet) + socketio.run( + app, + host=config["aprsd"]["web"]["host"], + port=config["aprsd"]["web"]["port"], + ) + + # If there are items in the msgTracker, then save them + LOG.info("APRSD Exiting.") + return 0 diff --git a/aprsd/dev.py b/aprsd/dev.py deleted file mode 100644 index eb952e2..0000000 --- a/aprsd/dev.py +++ /dev/null @@ -1,251 +0,0 @@ -# -# Dev.py is used to help develop plugins -# -# -# python included libs -import logging -from logging import NullHandler -from logging.handlers import RotatingFileHandler -import os -import sys - -import click -import click_completion - -# local imports here -import aprsd -from aprsd import client -from aprsd import config as aprsd_config -from aprsd import plugin - - -# setup the global logger -# logging.basicConfig(level=logging.DEBUG) # level=10 -LOG = logging.getLogger("APRSD") - -LOG_LEVELS = { - "CRITICAL": logging.CRITICAL, - "ERROR": logging.ERROR, - "WARNING": logging.WARNING, - "INFO": logging.INFO, - "DEBUG": logging.DEBUG, -} - -CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) - - -def custom_startswith(string, incomplete): - """A custom completion match that supports case insensitive matching.""" - if os.environ.get("_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE"): - string = string.lower() - incomplete = incomplete.lower() - return string.startswith(incomplete) - - -click_completion.core.startswith = custom_startswith -click_completion.init() - - -cmd_help = """Shell completion for click-completion-command -Available shell types: -\b - %s -Default type: auto -""" % "\n ".join( - f"{k:<12} {click_completion.core.shells[k]}" - for k in sorted(click_completion.core.shells.keys()) -) - - -@click.group(help=cmd_help, context_settings=CONTEXT_SETTINGS) -@click.version_option() -def main(): - pass - - -@main.command() -@click.option( - "-i", - "--case-insensitive/--no-case-insensitive", - help="Case insensitive completion", -) -@click.argument( - "shell", - required=False, - type=click_completion.DocumentedChoice(click_completion.core.shells), -) -def show(shell, case_insensitive): - """Show the click-completion-command completion code""" - extra_env = ( - {"_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE": "ON"} - if case_insensitive - else {} - ) - click.echo(click_completion.core.get_code(shell, extra_env=extra_env)) - - -@main.command() -@click.option( - "--append/--overwrite", - help="Append the completion code to the file", - default=None, -) -@click.option( - "-i", - "--case-insensitive/--no-case-insensitive", - help="Case insensitive completion", -) -@click.argument( - "shell", - required=False, - type=click_completion.DocumentedChoice(click_completion.core.shells), -) -@click.argument("path", required=False) -def install(append, case_insensitive, shell, path): - """Install the click-completion-command completion""" - extra_env = ( - {"_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE": "ON"} - if case_insensitive - else {} - ) - shell, path = click_completion.core.install( - shell=shell, - path=path, - append=append, - extra_env=extra_env, - ) - click.echo(f"{shell} completion installed in {path}") - - -# Setup the logging faciility -# to disable logging to stdout, but still log to file -# use the --quiet option on the cmdln -def setup_logging(config, loglevel, quiet): - log_level = aprsd_config.LOG_LEVELS[loglevel] - LOG.setLevel(log_level) - log_format = config["aprsd"].get( - "logformat", - aprsd_config.DEFAULT_LOG_FORMAT, - ) - date_format = config["aprsd"].get( - "dateformat", - aprsd_config.DEFAULT_DATE_FORMAT, - ) - log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format) - log_file = config["aprsd"].get("logfile", None) - if log_file: - fh = RotatingFileHandler( - log_file, maxBytes=(10248576 * 5), - backupCount=4, - ) - else: - fh = NullHandler() - - fh.setFormatter(log_formatter) - LOG.addHandler(fh) - - if not quiet: - sh = logging.StreamHandler(sys.stdout) - sh.setFormatter(log_formatter) - LOG.addHandler(sh) - - -@main.command() -@click.option( - "--loglevel", - default="DEBUG", - show_default=True, - type=click.Choice( - ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], - case_sensitive=False, - ), - show_choices=True, - help="The log level to use for aprsd.log", -) -@click.option( - "-c", - "--config", - "config_file", - show_default=True, - default=aprsd_config.DEFAULT_CONFIG_FILE, - help="The aprsd config file to use for options.", -) -@click.option( - "-p", - "--plugin", - "plugin_path", - show_default=True, - default="aprsd.plugins.wx.WxPlugin", - help="The plugin to run", -) -@click.option( - "-a", - "--all", - "load_all", - show_default=True, - is_flag=True, - default=False, - help="Load all the plugins in config?", -) -@click.option( - "-n", - "--num", - "number", - show_default=True, - default=1, - help="Number of times to call the plugin", -) -@click.argument("fromcall") -@click.argument("message", nargs=-1, required=True) -def test_plugin( - loglevel, - config_file, - plugin_path, - load_all, - number, - fromcall, - message, -): - """APRSD Plugin test app.""" - - config = aprsd_config.parse_config(config_file) - setup_logging(config, loglevel, False) - - LOG.info(f"Test APRSD Plgin version: {aprsd.__version__}") - if type(message) is tuple: - message = " ".join(message) - client.Client(config) - - pm = plugin.PluginManager(config) - if load_all: - pm.setup_plugins() - else: - pm._init() - obj = pm._create_class(plugin_path, plugin.APRSDPluginBase, config=config) - # Register the plugin they wanted tested. - LOG.info( - "Testing plugin {} Version {}".format( - obj.__class__, obj.version, - ), - ) - pm._pluggy_pm.register(obj) - login = config["aprs"]["login"] - - packet = { - "from": fromcall, "addresse": login, - "message_text": message, - "format": "message", - "msgNo": 1, - } - LOG.info(f"P'{plugin_path}' F'{fromcall}' C'{message}'") - - for x in range(number): - reply = pm.run(packet) - # Plugin might have threads, so lets stop them so we can exit. - # obj.stop_threads() - LOG.info(f"Result{x} = '{reply}'") - pm.stop() - - -if __name__ == "__main__": - main() diff --git a/aprsd/fake_aprs.py b/aprsd/fake_aprs.py deleted file mode 100644 index 8eafe6b..0000000 --- a/aprsd/fake_aprs.py +++ /dev/null @@ -1,84 +0,0 @@ -import argparse -import logging -from logging.handlers import RotatingFileHandler -import socketserver -import sys -import time - -from aprsd import utils - - -# command line args -parser = argparse.ArgumentParser() -parser.add_argument( - "--loglevel", - default="DEBUG", - choices=["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], - help="The log level to use for aprsd.log", -) -parser.add_argument("--quiet", action="store_true", help="Don't log to stdout") - -parser.add_argument("--port", default=9099, type=int, help="The port to listen on .") -parser.add_argument("--ip", default="127.0.0.1", help="The IP to listen on ") - -CONFIG = None -LOG = logging.getLogger("ARPSSERVER") - - -# Setup the logging faciility -# to disable logging to stdout, but still log to file -# use the --quiet option on the cmdln -def setup_logging(args): - global LOG - levels = { - "CRITICAL": logging.CRITICAL, - "ERROR": logging.ERROR, - "WARNING": logging.WARNING, - "INFO": logging.INFO, - "DEBUG": logging.DEBUG, - } - log_level = levels[args.loglevel] - - LOG.setLevel(log_level) - log_format = "%(asctime)s [%(threadName)-12.12s] [%(levelname)-5.5s]" " %(message)s" - date_format = "%m/%d/%Y %I:%M:%S %p" - log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format) - fh = RotatingFileHandler("aprs-server.log", maxBytes=(10248576 * 5), backupCount=4) - fh.setFormatter(log_formatter) - LOG.addHandler(fh) - - if not args.quiet: - sh = logging.StreamHandler(sys.stdout) - sh.setFormatter(log_formatter) - LOG.addHandler(sh) - - -class MyAPRSTCPHandler(socketserver.BaseRequestHandler): - def handle(self): - # self.request is the TCP socket connected to the client - self.data = self.request.recv(1024).strip() - LOG.debug(f"{self.client_address[0]} wrote:") - LOG.debug(self.data) - # just send back the same data, but upper-cased - self.request.sendall(self.data.upper()) - - -def main(): - global CONFIG - args = parser.parse_args() - setup_logging(args) - LOG.info("Test APRS server starting.") - time.sleep(1) - - CONFIG = utils.parse_config(args) - - ip = CONFIG["aprs"]["host"] - port = CONFIG["aprs"]["port"] - LOG.info(f"Start server listening on {args.ip}:{args.port}") - - with socketserver.TCPServer((ip, port), MyAPRSTCPHandler) as server: - server.serve_forever() - - -if __name__ == "__main__": - main() diff --git a/aprsd/healthcheck.py b/aprsd/healthcheck.py deleted file mode 100644 index 462661c..0000000 --- a/aprsd/healthcheck.py +++ /dev/null @@ -1,233 +0,0 @@ -# -# Used to fetch the stats url and determine if -# aprsd server is 'healthy' -# -# -# python included libs -import datetime -import json -import logging -from logging import NullHandler -from logging.handlers import RotatingFileHandler -import os -import re -import sys - -import click -import click_completion -import requests - -# local imports here -import aprsd -from aprsd import config as aprsd_config - - -# setup the global logger -# logging.basicConfig(level=logging.DEBUG) # level=10 -LOG = logging.getLogger("APRSD") - -LOG_LEVELS = { - "CRITICAL": logging.CRITICAL, - "ERROR": logging.ERROR, - "WARNING": logging.WARNING, - "INFO": logging.INFO, - "DEBUG": logging.DEBUG, -} - -CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) - - -def custom_startswith(string, incomplete): - """A custom completion match that supports case insensitive matching.""" - if os.environ.get("_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE"): - string = string.lower() - incomplete = incomplete.lower() - return string.startswith(incomplete) - - -click_completion.core.startswith = custom_startswith -click_completion.init() - - -cmd_help = """Shell completion for click-completion-command -Available shell types: -\b - %s -Default type: auto -""" % "\n ".join( - f"{k:<12} {click_completion.core.shells[k]}" - for k in sorted(click_completion.core.shells.keys()) -) - - -@click.group(help=cmd_help, context_settings=CONTEXT_SETTINGS) -@click.version_option() -def main(): - pass - - -@main.command() -@click.option( - "-i", - "--case-insensitive/--no-case-insensitive", - help="Case insensitive completion", -) -@click.argument( - "shell", - required=False, - type=click_completion.DocumentedChoice(click_completion.core.shells), -) -def show(shell, case_insensitive): - """Show the click-completion-command completion code""" - extra_env = ( - {"_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE": "ON"} - if case_insensitive - else {} - ) - click.echo(click_completion.core.get_code(shell, extra_env=extra_env)) - - -@main.command() -@click.option( - "--append/--overwrite", - help="Append the completion code to the file", - default=None, -) -@click.option( - "-i", - "--case-insensitive/--no-case-insensitive", - help="Case insensitive completion", -) -@click.argument( - "shell", - required=False, - type=click_completion.DocumentedChoice(click_completion.core.shells), -) -@click.argument("path", required=False) -def install(append, case_insensitive, shell, path): - """Install the click-completion-command completion""" - extra_env = ( - {"_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE": "ON"} - if case_insensitive - else {} - ) - shell, path = click_completion.core.install( - shell=shell, - path=path, - append=append, - extra_env=extra_env, - ) - click.echo(f"{shell} completion installed in {path}") - - -# Setup the logging faciility -# to disable logging to stdout, but still log to file -# use the --quiet option on the cmdln -def setup_logging(config, loglevel, quiet): - log_level = LOG_LEVELS[loglevel] - LOG.setLevel(log_level) - log_format = "[%(asctime)s] [%(threadName)-12s] [%(levelname)-5.5s]" " %(message)s" - date_format = "%m/%d/%Y %I:%M:%S %p" - log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format) - log_file = config["aprs"].get("logfile", None) - if log_file: - fh = RotatingFileHandler(log_file, maxBytes=(10248576 * 5), backupCount=4) - else: - fh = NullHandler() - - fh.setFormatter(log_formatter) - LOG.addHandler(fh) - - if not quiet: - sh = logging.StreamHandler(sys.stdout) - sh.setFormatter(log_formatter) - LOG.addHandler(sh) - - -def parse_delta_str(s): - if "day" in s: - m = re.match( - r"(?P[-\d]+) day[s]*, (?P\d+):(?P\d+):(?P\d[\.\d+]*)", - 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()} - - -@main.command() -@click.option( - "--loglevel", - default="INFO", - show_default=True, - type=click.Choice( - ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], - case_sensitive=False, - ), - show_choices=True, - help="The log level to use for aprsd.log", -) -@click.option( - "-c", - "--config", - "config_file", - show_default=True, - default=aprsd_config.DEFAULT_CONFIG_FILE, - help="The aprsd config file to use for options.", -) -@click.option( - "--url", - "health_url", - show_default=True, - default="http://localhost:8001/stats", - help="The aprsd url to call for checking health/stats", -) -@click.option( - "--timeout", - show_default=True, - default=3, - help="How long to wait for healtcheck url to come back", -) -def check(loglevel, config_file, health_url, timeout): - """APRSD Plugin test app.""" - - config = aprsd_config.parse_config(config_file) - - setup_logging(config, loglevel, False) - LOG.debug(f"APRSD HealthCheck version: {aprsd.__version__}") - - try: - url = health_url - response = requests.get(url, timeout=timeout) - response.raise_for_status() - except Exception as ex: - LOG.error(f"Failed to fetch healthcheck url '{url}' : '{ex}'") - sys.exit(-1) - else: - stats = json.loads(response.text) - LOG.debug(stats) - - email_thread_last_update = stats["stats"]["email"]["thread_last_update"] - - delta = parse_delta_str(email_thread_last_update) - d = datetime.timedelta(**delta) - max_timeout = {"hours": 0.0, "minutes": 5, "seconds": 0} - max_delta = datetime.timedelta(**max_timeout) - if d > max_delta: - LOG.error(f"Email thread is very old! {d}") - sys.exit(-1) - - aprsis_last_update = stats["stats"]["aprs-is"]["last_update"] - delta = parse_delta_str(aprsis_last_update) - d = datetime.timedelta(**delta) - max_timeout = {"hours": 0.0, "minutes": 5, "seconds": 0} - max_delta = datetime.timedelta(**max_timeout) - if d > max_delta: - LOG.error(f"APRS-IS last update is very old! {d}") - sys.exit(-1) - - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/aprsd/listen.py b/aprsd/listen.py deleted file mode 100644 index 40d90b7..0000000 --- a/aprsd/listen.py +++ /dev/null @@ -1,394 +0,0 @@ -# -# Listen on amateur radio aprs-is network for messages and respond to them. -# You must have an amateur radio callsign to use this software. You must -# create an ~/.aprsd/config.yml file with all of the required settings. To -# generate an example config.yml, just run aprsd, then copy the sample config -# to ~/.aprsd/config.yml and edit the settings. -# -# APRS messages: -# l(ocation) = descriptive location of calling station -# w(eather) = temp, (hi/low) forecast, later forecast -# t(ime) = respond with the current time -# f(ortune) = respond with a short fortune -# -email_addr email text = send an email -# -2 = display the last 2 emails received -# p(ing) = respond with Pong!/time -# anything else = respond with usage -# -# (C)2018 Craig Lamparter -# License GPLv2 -# - -# python included libs -import datetime -import logging -from logging import NullHandler -from logging.handlers import RotatingFileHandler -import os -import signal -import sys -import time - -import aprslib -from aprslib.exceptions import LoginError -import click -import click_completion - -# local imports here -import aprsd -from aprsd import client -from aprsd import config as aprsd_config -from aprsd import messaging, stats, threads, trace, utils - - -# setup the global logger -# logging.basicConfig(level=logging.DEBUG) # level=10 -LOG = logging.getLogger("APRSD") - - -CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) - -flask_enabled = False - -# server_event = threading.Event() - -# localization, please edit: -# HOST = "noam.aprs2.net" # north america tier2 servers round robin -# USER = "KM6XXX-9" # callsign of this aprs client with SSID -# PASS = "99999" # google how to generate this -# BASECALLSIGN = "KM6XXX" # callsign of radio in the field to send email -# shortcuts = { -# "aa" : "5551239999@vtext.com", -# "cl" : "craiglamparter@somedomain.org", -# "wb" : "5553909472@vtext.com" -# } - - -def custom_startswith(string, incomplete): - """A custom completion match that supports case insensitive matching.""" - if os.environ.get("_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE"): - string = string.lower() - incomplete = incomplete.lower() - return string.startswith(incomplete) - - -click_completion.core.startswith = custom_startswith -click_completion.init() - - -cmd_help = """Shell completion for click-completion-command -Available shell types: -\b - %s -Default type: auto -""" % "\n ".join( - f"{k:<12} {click_completion.core.shells[k]}" - for k in sorted(click_completion.core.shells.keys()) -) - - -@click.group(help=cmd_help, context_settings=CONTEXT_SETTINGS) -@click.version_option() -def main(): - pass - - -@main.command() -@click.option( - "-i", - "--case-insensitive/--no-case-insensitive", - help="Case insensitive completion", -) -@click.argument( - "shell", - required=False, - type=click_completion.DocumentedChoice(click_completion.core.shells), -) -def show(shell, case_insensitive): - """Show the click-completion-command completion code""" - extra_env = ( - {"_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE": "ON"} - if case_insensitive - else {} - ) - click.echo(click_completion.core.get_code(shell, extra_env=extra_env)) - - -@main.command() -@click.option( - "--append/--overwrite", - help="Append the completion code to the file", - default=None, -) -@click.option( - "-i", - "--case-insensitive/--no-case-insensitive", - help="Case insensitive completion", -) -@click.argument( - "shell", - required=False, - type=click_completion.DocumentedChoice(click_completion.core.shells), -) -@click.argument("path", required=False) -def install(append, case_insensitive, shell, path): - """Install the click-completion-command completion""" - extra_env = ( - {"_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE": "ON"} - if case_insensitive - else {} - ) - shell, path = click_completion.core.install( - shell=shell, - path=path, - append=append, - extra_env=extra_env, - ) - click.echo(f"{shell} completion installed in {path}") - - -def signal_handler(sig, frame): - global flask_enabled - - threads.APRSDThreadList().stop_all() - if "subprocess" not in str(frame): - LOG.info( - "Ctrl+C, Sending all threads exit! Can take up to 10 seconds {}".format( - datetime.datetime.now(), - ), - ) - time.sleep(5) - tracker = messaging.MsgTrack() - tracker.save() - LOG.info(stats.APRSDStats()) - # signal.signal(signal.SIGTERM, sys.exit(0)) - # sys.exit(0) - if flask_enabled: - signal.signal(signal.SIGTERM, sys.exit(0)) - - -# Setup the logging faciility -# to disable logging to stdout, but still log to file -# use the --quiet option on the cmdln -def setup_logging(config, loglevel, quiet): - log_level = aprsd_config.LOG_LEVELS[loglevel] - LOG.setLevel(log_level) - log_format = config["aprsd"].get("logformat", aprsd_config.DEFAULT_LOG_FORMAT) - date_format = config["aprsd"].get("dateformat", aprsd_config.DEFAULT_DATE_FORMAT) - log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format) - log_file = config["aprsd"].get("logfile", None) - if log_file: - fh = RotatingFileHandler(log_file, maxBytes=(10248576 * 5), backupCount=4) - else: - fh = NullHandler() - - fh.setFormatter(log_formatter) - LOG.addHandler(fh) - - imap_logger = None - if config["aprsd"]["email"].get("enabled", False) and config["aprsd"]["email"][ - "imap" - ].get("debug", False): - - imap_logger = logging.getLogger("imapclient.imaplib") - imap_logger.setLevel(log_level) - imap_logger.addHandler(fh) - - if not quiet: - sh = logging.StreamHandler(sys.stdout) - sh.setFormatter(log_formatter) - LOG.addHandler(sh) - if imap_logger: - imap_logger.addHandler(sh) - - -@main.command() -@click.option( - "--loglevel", - default="DEBUG", - show_default=True, - type=click.Choice( - ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], - case_sensitive=False, - ), - show_choices=True, - help="The log level to use for aprsd.log", -) -@click.option("--quiet", is_flag=True, default=False, help="Don't log to stdout") -@click.option( - "-c", - "--config", - "config_file", - show_default=True, - default=aprsd_config.DEFAULT_CONFIG_FILE, - help="The aprsd config file to use for options.", -) -@click.option( - "--aprs-login", - envvar="APRS_LOGIN", - show_envvar=True, - help="What callsign to send the message from.", -) -@click.option( - "--aprs-password", - envvar="APRS_PASSWORD", - show_envvar=True, - help="the APRS-IS password for APRS_LOGIN", -) -@click.option( - "--no-ack", - "-n", - is_flag=True, - show_default=True, - default=False, - help="Don't wait for an ack, just sent it to APRS-IS and bail.", -) -@click.option("--raw", default=None, help="Send a raw message. Implies --no-ack") -@click.argument("tocallsign", required=False) -@click.argument("command", nargs=-1, required=False) -def listen( - loglevel, - quiet, - config_file, - aprs_login, - aprs_password, - no_ack, - raw, - tocallsign, - command, -): - """Send a message to a callsign via APRS_IS.""" - global got_ack, got_response - - config = aprsd_config.parse_config(config_file) - if not aprs_login: - click.echo("Must set --aprs_login or APRS_LOGIN") - return - - if not aprs_password: - click.echo("Must set --aprs-password or APRS_PASSWORD") - return - - config["aprs"]["login"] = aprs_login - config["aprs"]["password"] = aprs_password - messaging.CONFIG = config - - setup_logging(config, loglevel, quiet) - LOG.info(f"APRSD TEST Started version: {aprsd.__version__}") - if type(command) is tuple: - command = " ".join(command) - if not quiet: - if raw: - LOG.info(f"L'{aprs_login}' R'{raw}'") - else: - LOG.info(f"L'{aprs_login}' To'{tocallsign}' C'{command}'") - - 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]}") - - got_ack = False - got_response = False - - # TODO(walt) - manually edit this list - # prior to running aprsd-listen listen - watch_list = ["k*"] - - # build last seen list - last_seen = {} - for callsign in watch_list: - call = callsign.replace("*", "") - last_seen[call] = datetime.datetime.now() - - LOG.debug("Last seen list") - LOG.debug(last_seen) - - @trace.trace - def rx_packet(packet): - global got_ack, got_response - LOG.debug("Got packet back {}".format(packet["raw"])) - - if packet["from"] in last_seen: - now = datetime.datetime.now() - age = str(now - last_seen[packet["from"]]) - - delta = utils.parse_delta_str(age) - d = datetime.timedelta(**delta) - - max_timeout = { - "seconds": config["aprsd"]["watch_list"]["alert_time_seconds"], - } - max_delta = datetime.timedelta(**max_timeout) - if d > max_delta: - LOG.debug( - "NOTIFY!!! {} last seen {} max age={}".format( - packet["from"], - age, - max_delta, - ), - ) - else: - LOG.debug(f"Not old enough to notify {d} < {max_delta}") - LOG.debug("Update last seen from {}".format(packet["from"])) - last_seen[packet["from"]] = now - else: - LOG.debug( - "ignoring packet because {} not in watch list".format(packet["from"]), - ) - - resp = packet.get("response", None) - if resp == "ack": - ack_num = packet.get("msgNo") - LOG.info(f"We saw an ACK {ack_num} Ignoring") - # messaging.log_packet(packet) - got_ack = True - else: - message = packet.get("message_text", None) - fromcall = packet["from"] - msg_number = packet.get("msgNo", "0") - messaging.log_message( - "Received Message", - packet["raw"], - message, - fromcall=fromcall, - ack=msg_number, - ) - - try: - cl = client.Client(config) - cl.setup_connection() - except LoginError: - sys.exit(-1) - - aprs_client = client.get_client() - - # filter_str = 'b/{}'.format('/'.join(watch_list)) - # LOG.debug("Filter by '{}'".format(filter_str)) - # aprs_client.set_filter(filter_str) - filter_str = "p/{}".format("/".join(watch_list)) - LOG.debug(f"Filter by '{filter_str}'") - aprs_client.set_filter(filter_str) - - while True: - try: - # This will register a packet consumer with aprslib - # When new packets come in the consumer will process - # the packet - aprs_client.consumer(rx_packet, raw=False) - except aprslib.exceptions.ConnectionDrop: - LOG.error("Connection dropped, reconnecting") - time.sleep(5) - # Force the deletion of the client object connected to aprs - # This will cause a reconnect, next time client.get_client() - # is called - cl.reset() - except aprslib.exceptions.UnknownFormat: - LOG.error("Got a shitty packet") - - -if __name__ == "__main__": - main() diff --git a/aprsd/log.py b/aprsd/log.py new file mode 100644 index 0000000..71ecf2c --- /dev/null +++ b/aprsd/log.py @@ -0,0 +1,53 @@ +import logging +from logging import NullHandler +from logging.handlers import RotatingFileHandler +import queue +import sys + +from aprsd import config as aprsd_config + + +LOG = logging.getLogger("APRSD") +logging_queue = queue.Queue() + + +# Setup the logging faciility +# to disable logging to stdout, but still log to file +# use the --quiet option on the cmdln +def setup_logging(config, loglevel, quiet): + log_level = aprsd_config.LOG_LEVELS[loglevel] + LOG.setLevel(log_level) + log_format = config["aprsd"].get("logformat", aprsd_config.DEFAULT_LOG_FORMAT) + date_format = config["aprsd"].get("dateformat", aprsd_config.DEFAULT_DATE_FORMAT) + log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format) + log_file = config["aprsd"].get("logfile", None) + if log_file: + fh = RotatingFileHandler(log_file, maxBytes=(10248576 * 5), backupCount=4) + else: + fh = NullHandler() + + fh.setFormatter(log_formatter) + LOG.addHandler(fh) + + imap_logger = None + if config.get("aprsd.email.enabled", default=False) and config.get("aprsd.email.imap.debug", default=False): + + imap_logger = logging.getLogger("imapclient.imaplib") + imap_logger.setLevel(log_level) + imap_logger.addHandler(fh) + + if config.get("aprsd.web.enabled", default=False): + qh = logging.handlers.QueueHandler(logging_queue) + q_log_formatter = logging.Formatter( + fmt=aprsd_config.QUEUE_LOG_FORMAT, + datefmt=aprsd_config.QUEUE_DATE_FORMAT, + ) + qh.setFormatter(q_log_formatter) + LOG.addHandler(qh) + + if not quiet: + sh = logging.StreamHandler(sys.stdout) + sh.setFormatter(log_formatter) + LOG.addHandler(sh) + if imap_logger: + imap_logger.addHandler(sh) diff --git a/aprsd/main.py b/aprsd/main.py deleted file mode 100644 index b07f8fd..0000000 --- a/aprsd/main.py +++ /dev/null @@ -1,520 +0,0 @@ -# -# Listen on amateur radio aprs-is network for messages and respond to them. -# You must have an amateur radio callsign to use this software. You must -# create an ~/.aprsd/config.yml file with all of the required settings. To -# generate an example config.yml, just run aprsd, then copy the sample config -# to ~/.aprsd/config.yml and edit the settings. -# -# APRS messages: -# l(ocation) = descriptive location of calling station -# w(eather) = temp, (hi/low) forecast, later forecast -# t(ime) = respond with the current time -# f(ortune) = respond with a short fortune -# -email_addr email text = send an email -# -2 = display the last 2 emails received -# p(ing) = respond with Pong!/time -# anything else = respond with usage -# -# (C)2018 Craig Lamparter -# License GPLv2 -# - -# python included libs -import datetime -import logging -from logging import NullHandler -from logging.handlers import RotatingFileHandler -import os -import signal -import sys -import time - -import aprslib -from aprslib.exceptions import LoginError -import click -import click_completion - -# local imports here -import aprsd -from aprsd import ( - flask, messaging, packets, plugin, stats, threads, trace, utils, -) -from aprsd import client -from aprsd import config as aprsd_config - - -# setup the global logger -# logging.basicConfig(level=logging.DEBUG) # level=10 -LOG = logging.getLogger("APRSD") - - -CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) -flask_enabled = False - - -def custom_startswith(string, incomplete): - """A custom completion match that supports case insensitive matching.""" - if os.environ.get("_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE"): - string = string.lower() - incomplete = incomplete.lower() - return string.startswith(incomplete) - - -click_completion.core.startswith = custom_startswith -click_completion.init() - - -cmd_help = """Shell completion for click-completion-command -Available shell types: -\b - %s -Default type: auto -""" % "\n ".join( - f"{k:<12} {click_completion.core.shells[k]}" - for k in sorted(click_completion.core.shells.keys()) -) - - -@click.group(help=cmd_help, context_settings=CONTEXT_SETTINGS) -@click.version_option() -def main(): - pass - - -@main.command() -@click.option( - "-i", - "--case-insensitive/--no-case-insensitive", - help="Case insensitive completion", -) -@click.argument( - "shell", - required=False, - type=click_completion.DocumentedChoice(click_completion.core.shells), -) -def show(shell, case_insensitive): - """Show the click-completion-command completion code""" - extra_env = ( - {"_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE": "ON"} - if case_insensitive - else {} - ) - click.echo(click_completion.core.get_code(shell, extra_env=extra_env)) - - -@main.command() -@click.option( - "--append/--overwrite", - help="Append the completion code to the file", - default=None, -) -@click.option( - "-i", - "--case-insensitive/--no-case-insensitive", - help="Case insensitive completion", -) -@click.argument( - "shell", - required=False, - type=click_completion.DocumentedChoice(click_completion.core.shells), -) -@click.argument("path", required=False) -def install(append, case_insensitive, shell, path): - """Install the click-completion-command completion""" - extra_env = ( - {"_CLICK_COMPLETION_COMMAND_CASE_INSENSITIVE_COMPLETE": "ON"} - if case_insensitive - else {} - ) - shell, path = click_completion.core.install( - shell=shell, - path=path, - append=append, - extra_env=extra_env, - ) - click.echo(f"{shell} completion installed in {path}") - - -def signal_handler(sig, frame): - global flask_enabled - - threads.APRSDThreadList().stop_all() - if "subprocess" not in str(frame): - LOG.info( - "Ctrl+C, Sending all threads exit! Can take up to 10 seconds {}".format( - datetime.datetime.now(), - ), - ) - time.sleep(1.5) - messaging.MsgTrack().save() - packets.WatchList().save() - packets.SeenList().save() - LOG.info(stats.APRSDStats()) - # signal.signal(signal.SIGTERM, sys.exit(0)) - # sys.exit(0) - if flask_enabled: - signal.signal(signal.SIGTERM, sys.exit(0)) - - -# Setup the logging faciility -# to disable logging to stdout, but still log to file -# use the --quiet option on the cmdln -def setup_logging(config, loglevel, quiet): - log_level = aprsd_config.LOG_LEVELS[loglevel] - LOG.setLevel(log_level) - log_format = config["aprsd"].get("logformat", aprsd_config.DEFAULT_LOG_FORMAT) - date_format = config["aprsd"].get("dateformat", aprsd_config.DEFAULT_DATE_FORMAT) - log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format) - log_file = config["aprsd"].get("logfile", None) - if log_file: - fh = RotatingFileHandler(log_file, maxBytes=(10248576 * 5), backupCount=4) - else: - fh = NullHandler() - - fh.setFormatter(log_formatter) - LOG.addHandler(fh) - - imap_logger = None - if config.get("aprsd.email.enabled", default=False) and config.get("aprsd.email.imap.debug", default=False): - - imap_logger = logging.getLogger("imapclient.imaplib") - imap_logger.setLevel(log_level) - imap_logger.addHandler(fh) - - if config.get("aprsd.web.enabled", default=False): - qh = logging.handlers.QueueHandler(threads.logging_queue) - q_log_formatter = logging.Formatter( - fmt=aprsd_config.QUEUE_LOG_FORMAT, - datefmt=aprsd_config.QUEUE_DATE_FORMAT, - ) - qh.setFormatter(q_log_formatter) - LOG.addHandler(qh) - - if not quiet: - sh = logging.StreamHandler(sys.stdout) - sh.setFormatter(log_formatter) - LOG.addHandler(sh) - if imap_logger: - imap_logger.addHandler(sh) - - -@main.command() -@click.option( - "--loglevel", - default="INFO", - show_default=True, - type=click.Choice( - ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], - case_sensitive=False, - ), - show_choices=True, - help="The log level to use for aprsd.log", -) -@click.option( - "-c", - "--config", - "config_file", - show_default=True, - default=aprsd_config.DEFAULT_CONFIG_FILE, - help="The aprsd config file to use for options.", -) -def check_version(loglevel, config_file): - config = aprsd_config.parse_config(config_file) - - setup_logging(config, loglevel, False) - level, msg = utils._check_version() - if level: - LOG.warning(msg) - else: - LOG.info(msg) - - -@main.command() -def sample_config(): - """This dumps the config to stdout.""" - click.echo(aprsd_config.dump_default_cfg()) - - -@main.command() -@click.option( - "--loglevel", - default="DEBUG", - show_default=True, - type=click.Choice( - ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], - case_sensitive=False, - ), - show_choices=True, - help="The log level to use for aprsd.log", -) -@click.option("--quiet", is_flag=True, default=False, help="Don't log to stdout") -@click.option( - "-c", - "--config", - "config_file", - show_default=True, - default=aprsd_config.DEFAULT_CONFIG_FILE, - help="The aprsd config file to use for options.", -) -@click.option( - "--aprs-login", - envvar="APRS_LOGIN", - show_envvar=True, - help="What callsign to send the message from.", -) -@click.option( - "--aprs-password", - envvar="APRS_PASSWORD", - show_envvar=True, - help="the APRS-IS password for APRS_LOGIN", -) -@click.option( - "--no-ack", - "-n", - is_flag=True, - show_default=True, - default=False, - help="Don't wait for an ack, just sent it to APRS-IS and bail.", -) -@click.option("--raw", default=None, help="Send a raw message. Implies --no-ack") -@click.argument("tocallsign", required=False) -@click.argument("command", nargs=-1, required=False) -def send_message( - loglevel, - quiet, - config_file, - aprs_login, - aprs_password, - no_ack, - raw, - tocallsign, - command, -): - """Send a message to a callsign via APRS_IS.""" - global got_ack, got_response - - config = aprsd_config.parse_config(config_file) - if not aprs_login: - click.echo("Must set --aprs_login or APRS_LOGIN") - return - - if not aprs_password: - click.echo("Must set --aprs-password or APRS_PASSWORD") - return - - config["aprs"]["login"] = aprs_login - config["aprs"]["password"] = aprs_password - messaging.CONFIG = config - - setup_logging(config, loglevel, quiet) - LOG.info(f"APRSD Started version: {aprsd.__version__}") - if type(command) is tuple: - command = " ".join(command) - if not quiet: - if raw: - LOG.info(f"L'{aprs_login}' R'{raw}'") - else: - LOG.info(f"L'{aprs_login}' To'{tocallsign}' C'{command}'") - - got_ack = False - got_response = False - - def rx_packet(packet): - global got_ack, got_response - # LOG.debug("Got packet back {}".format(packet)) - resp = packet.get("response", None) - if resp == "ack": - ack_num = packet.get("msgNo") - LOG.info(f"We got ack for our sent message {ack_num}") - messaging.log_packet(packet) - got_ack = True - else: - message = packet.get("message_text", None) - fromcall = packet["from"] - msg_number = packet.get("msgNo", "0") - messaging.log_message( - "Received Message", - packet["raw"], - message, - fromcall=fromcall, - ack=msg_number, - ) - got_response = True - # Send the ack back? - ack = messaging.AckMessage( - config["aprs"]["login"], - fromcall, - msg_id=msg_number, - ) - ack.send_direct() - - if got_ack and got_response: - sys.exit(0) - - try: - client.ClientFactory.setup(config) - client.factory.create().client - except LoginError: - sys.exit(-1) - - packets.PacketList(config=config) - packets.WatchList(config=config) - - # Send a message - # then we setup a consumer to rx messages - # We should get an ack back as well as a new message - # we should bail after we get the ack and send an ack back for the - # message - if raw: - msg = messaging.RawMessage(raw) - msg.send_direct() - sys.exit(0) - else: - msg = messaging.TextMessage(aprs_login, tocallsign, command) - msg.send_direct() - - if no_ack: - sys.exit(0) - - try: - # This will register a packet consumer with aprslib - # When new packets come in the consumer will process - # the packet - aprs_client = client.factory.create().client - aprs_client.consumer(rx_packet, raw=False) - except aprslib.exceptions.ConnectionDrop: - LOG.error("Connection dropped, reconnecting") - time.sleep(5) - # Force the deletion of the client object connected to aprs - # This will cause a reconnect, next time client.get_client() - # is called - aprs_client.reset() - - -# main() ### -@main.command() -@click.option( - "--loglevel", - default="INFO", - show_default=True, - type=click.Choice( - ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], - case_sensitive=False, - ), - show_choices=True, - help="The log level to use for aprsd.log", -) -@click.option("--quiet", is_flag=True, default=False, help="Don't log to stdout") -@click.option( - "-c", - "--config", - "config_file", - show_default=True, - default=aprsd_config.DEFAULT_CONFIG_FILE, - help="The aprsd config file to use for options.", -) -@click.option( - "-f", - "--flush", - "flush", - is_flag=True, - show_default=True, - default=False, - help="Flush out all old aged messages on disk.", -) -def server( - loglevel, - quiet, - config_file, - flush, -): - """Start the aprsd server process.""" - global flask_enabled - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - - if not quiet: - click.echo("Load config") - - config = aprsd_config.parse_config(config_file) - - setup_logging(config, loglevel, quiet) - 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]}") - - if config["aprsd"].get("trace", False): - trace.setup_tracing(["method", "api"]) - 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) - - # Creates the client object - LOG.info("Creating client connection") - client.factory.create().client - - # Now load the msgTrack from disk if any - packets.PacketList(config=config) - if flush: - LOG.debug("Deleting saved MsgTrack.") - messaging.MsgTrack(config=config).flush() - packets.WatchList(config=config) - packets.SeenList(config=config) - else: - # Try and load saved MsgTrack list - LOG.debug("Loading saved MsgTrack object.") - messaging.MsgTrack(config=config).load() - packets.WatchList(config=config).load() - packets.SeenList(config=config).load() - - # Create the initial PM singleton and Register plugins - LOG.info("Loading Plugin Manager and registering plugins") - plugin_manager = plugin.PluginManager(config) - plugin_manager.setup_plugins() - - rx_thread = threads.APRSDRXThread( - msg_queues=threads.msg_queues, - config=config, - ) - rx_thread.start() - - messaging.MsgTrack().restart() - - keepalive = threads.KeepAliveThread(config=config) - keepalive.start() - - web_enabled = config.get("aprsd.web.enabled", default=False) - - if web_enabled: - flask_enabled = True - (socketio, app) = flask.init_flask(config, loglevel, quiet) - socketio.run( - app, - host=config["aprsd"]["web"]["host"], - port=config["aprsd"]["web"]["port"], - ) - - # If there are items in the msgTracker, then save them - LOG.info("APRSD Exiting.") - return 0 - - -if __name__ == "__main__": - main() diff --git a/aprsd/threads.py b/aprsd/threads.py index 3e6efb5..3d5350b 100644 --- a/aprsd/threads.py +++ b/aprsd/threads.py @@ -17,7 +17,6 @@ RX_THREAD = "RX" EMAIL_THREAD = "Email" rx_msg_queue = queue.Queue(maxsize=20) -logging_queue = queue.Queue() msg_queues = { "rx": rx_msg_queue, } diff --git a/setup.cfg b/setup.cfg index bad2b0c..a254fed 100644 --- a/setup.cfg +++ b/setup.cfg @@ -33,11 +33,7 @@ packages = [entry_points] console_scripts = - aprsd = aprsd.main:main - aprsd-listen = aprsd.listen:main - aprsd-dev = aprsd.dev:main - aprsd-healthcheck = aprsd.healthcheck:main - fake_aprs = aprsd.fake_aprs:main + aprsd = aprsd.aprsd:main [build_sphinx] source-dir = docs