mirror of
https://github.com/craigerl/aprsd.git
synced 2024-09-27 07:36:40 -04:00
Hemna
1a1fcba1c4
This patch adds a new optional feature called Watch list. Aprsd will filter IN all aprs packets from a list of callsigns. APRSD will keep track of the last time a callsign has been seen. When the configured timeout value has been reached, the next time a callsign is seen, APRSD will send the next packet from that callsign through the new notification plugins list. The new BaseNotifyPlugin is the default core APRSD notify based plugin. When it gets a packet it will construct a reply message to be sent to the configured alert callsign to alert them that the seen callsign is now on the APRS network. This basically acts as a notification that your watched callsign list is available on APRS. The new configuration options: aprsd: watch_list: # The callsign to send a message to once a watch list callsign # is now seen on APRS-IS alert_callsign: NOCALL # The time in seconds to wait for notification. # The default is 12 hours. alert_time_seconds: 43200 # The list of callsigns to watch for callsigns: - WB4BOR - KFART # Enable/disable this feature enabled: false # The list of notify based plugins to load for # processing a new seen packet from a callsign. enabled_plugins: - aprsd.plugins.notify.BaseNotifyPlugin This patch also adds a new section in the Admin UI for showing the watch list and the age of the last seen packet for each callsing since APRSD startup.
391 lines
11 KiB
Python
391 lines
11 KiB
Python
#
|
|
# 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
|
|
|
|
# local imports here
|
|
import aprsd
|
|
from aprsd import client, messaging, stats, threads, trace, utils
|
|
import aprslib
|
|
from aprslib.exceptions import LoginError
|
|
import click
|
|
import click_completion
|
|
|
|
# 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(
|
|
"{:<12} {}".format(k, 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("{} completion installed in {}".format(shell, 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 = utils.LOG_LEVELS[loglevel]
|
|
LOG.setLevel(log_level)
|
|
log_format = config["aprsd"].get("logformat", utils.DEFAULT_LOG_FORMAT)
|
|
date_format = config["aprsd"].get("dateformat", utils.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=utils.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 = utils.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("APRSD TEST Started version: {}".format(aprsd.__version__))
|
|
if type(command) is tuple:
|
|
command = " ".join(command)
|
|
if not quiet:
|
|
if raw:
|
|
LOG.info("L'{}' R'{}'".format(aprs_login, raw))
|
|
else:
|
|
LOG.info("L'{}' To'{}' C'{}'".format(aprs_login, tocallsign, 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("{} = XXXXXXXXXXXXXXXXXXX".format(x))
|
|
else:
|
|
LOG.info("{} = {}".format(x, flat_config[x]))
|
|
|
|
got_ack = False
|
|
got_response = False
|
|
|
|
# TODO(walt) - manually edit this list
|
|
# prior to running aprsd-listen listen
|
|
watch_list = []
|
|
|
|
# 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("Not old enough to notify {} < {}".format(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("We saw an ACK {} Ignoring".format(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,
|
|
)
|
|
|
|
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("Filter by '{}'".format(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()
|