mirror of
https://github.com/craigerl/aprsd.git
synced 2025-07-30 12:22:27 -04:00
commit
923e1a7c3d
2
.github/workflows/python.yml
vendored
2
.github/workflows/python.yml
vendored
@ -7,7 +7,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [2.7, 3.6, 3.7, 3.8, 3.9]
|
python-version: [3.6, 3.7, 3.8, 3.9]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
63
aprsd/client.py
Normal file
63
aprsd/client.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
import aprslib
|
||||||
|
|
||||||
|
LOG = logging.getLogger("APRSD")
|
||||||
|
|
||||||
|
|
||||||
|
class Client(object):
|
||||||
|
"""Singleton client class that constructs the aprslib connection."""
|
||||||
|
|
||||||
|
_instance = None
|
||||||
|
aprs_client = None
|
||||||
|
config = None
|
||||||
|
|
||||||
|
def __new__(cls, *args, **kwargs):
|
||||||
|
"""This magic turns this into a singleton."""
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super(Client, cls).__new__(cls)
|
||||||
|
# Put any initialization here.
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self, config=None):
|
||||||
|
"""Initialize the object instance."""
|
||||||
|
if config:
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client(self):
|
||||||
|
if not self.aprs_client:
|
||||||
|
self.aprs_client = self.setup_connection()
|
||||||
|
return self.aprs_client
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Call this to force a rebuild/reconnect."""
|
||||||
|
del self.aprs_client
|
||||||
|
|
||||||
|
def setup_connection(self):
|
||||||
|
user = self.config["aprs"]["login"]
|
||||||
|
password = self.config["aprs"]["password"]
|
||||||
|
host = self.config["aprs"].get("host", "rotate.aprs.net")
|
||||||
|
port = self.config["aprs"].get("port", 14580)
|
||||||
|
connected = False
|
||||||
|
while not connected:
|
||||||
|
try:
|
||||||
|
LOG.info("Creating aprslib client")
|
||||||
|
aprs_client = aprslib.IS(user, passwd=password, host=host, port=port)
|
||||||
|
# Force the logging to be the same
|
||||||
|
aprs_client.logger = LOG
|
||||||
|
aprs_client.connect()
|
||||||
|
connected = True
|
||||||
|
except Exception as e:
|
||||||
|
LOG.error("Unable to connect to APRS-IS server.\n")
|
||||||
|
print(str(e))
|
||||||
|
time.sleep(5)
|
||||||
|
continue
|
||||||
|
LOG.debug("Logging in to APRS-IS with user '%s'" % user)
|
||||||
|
return aprs_client
|
||||||
|
|
||||||
|
|
||||||
|
def get_client():
|
||||||
|
cl = Client()
|
||||||
|
return cl.client
|
399
aprsd/email.py
Normal file
399
aprsd/email.py
Normal file
@ -0,0 +1,399 @@
|
|||||||
|
import datetime
|
||||||
|
import email
|
||||||
|
import imaplib
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import smtplib
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
import imapclient
|
||||||
|
import six
|
||||||
|
from validate_email import validate_email
|
||||||
|
|
||||||
|
from aprsd import messaging
|
||||||
|
|
||||||
|
LOG = logging.getLogger("APRSD")
|
||||||
|
|
||||||
|
# This gets forced set from main.py prior to being used internally
|
||||||
|
CONFIG = None
|
||||||
|
|
||||||
|
|
||||||
|
def start_thread():
|
||||||
|
checkemailthread = threading.Thread(
|
||||||
|
target=check_email_thread, name="check_email", args=()
|
||||||
|
) # args must be tuple
|
||||||
|
checkemailthread.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _imap_connect():
|
||||||
|
imap_port = CONFIG["imap"].get("port", 143)
|
||||||
|
use_ssl = CONFIG["imap"].get("use_ssl", False)
|
||||||
|
host = CONFIG["imap"]["host"]
|
||||||
|
msg = "{}{}:{}".format("TLS " if use_ssl else "", host, imap_port)
|
||||||
|
# LOG.debug("Connect to IMAP host {} with user '{}'".
|
||||||
|
# format(msg, CONFIG['imap']['login']))
|
||||||
|
|
||||||
|
try:
|
||||||
|
server = imapclient.IMAPClient(
|
||||||
|
CONFIG["imap"]["host"], port=imap_port, use_uid=True, ssl=use_ssl
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
LOG.error("Failed to connect IMAP server")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
server.login(CONFIG["imap"]["login"], CONFIG["imap"]["password"])
|
||||||
|
except (imaplib.IMAP4.error, Exception) as e:
|
||||||
|
msg = getattr(e, "message", repr(e))
|
||||||
|
LOG.error("Failed to login {}".format(msg))
|
||||||
|
return
|
||||||
|
|
||||||
|
server.select_folder("INBOX")
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
def _smtp_connect():
|
||||||
|
host = CONFIG["smtp"]["host"]
|
||||||
|
smtp_port = CONFIG["smtp"]["port"]
|
||||||
|
use_ssl = CONFIG["smtp"].get("use_ssl", False)
|
||||||
|
msg = "{}{}:{}".format("SSL " if use_ssl else "", host, smtp_port)
|
||||||
|
LOG.debug(
|
||||||
|
"Connect to SMTP host {} with user '{}'".format(msg, CONFIG["imap"]["login"])
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if use_ssl:
|
||||||
|
server = smtplib.SMTP_SSL(host=host, port=smtp_port)
|
||||||
|
else:
|
||||||
|
server = smtplib.SMTP(host=host, port=smtp_port)
|
||||||
|
except Exception:
|
||||||
|
LOG.error("Couldn't connect to SMTP Server")
|
||||||
|
return
|
||||||
|
|
||||||
|
LOG.debug("Connected to smtp host {}".format(msg))
|
||||||
|
|
||||||
|
try:
|
||||||
|
server.login(CONFIG["smtp"]["login"], CONFIG["smtp"]["password"])
|
||||||
|
except Exception:
|
||||||
|
LOG.error("Couldn't connect to SMTP Server")
|
||||||
|
return
|
||||||
|
|
||||||
|
LOG.debug("Logged into SMTP server {}".format(msg))
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
def validate_shortcuts(config):
|
||||||
|
shortcuts = config.get("shortcuts", None)
|
||||||
|
if not shortcuts:
|
||||||
|
return
|
||||||
|
|
||||||
|
LOG.info(
|
||||||
|
"Validating {} Email shortcuts. This can take up to 10 seconds"
|
||||||
|
" per shortcut".format(len(shortcuts))
|
||||||
|
)
|
||||||
|
delete_keys = []
|
||||||
|
for key in shortcuts:
|
||||||
|
is_valid = validate_email(
|
||||||
|
email_address=shortcuts[key],
|
||||||
|
check_regex=True,
|
||||||
|
check_mx=True,
|
||||||
|
from_address=config["smtp"]["login"],
|
||||||
|
helo_host=config["smtp"]["host"],
|
||||||
|
smtp_timeout=10,
|
||||||
|
dns_timeout=10,
|
||||||
|
use_blacklist=False,
|
||||||
|
debug=False,
|
||||||
|
)
|
||||||
|
if not is_valid:
|
||||||
|
LOG.error(
|
||||||
|
"'{}' is an invalid email address. Removing shortcut".format(
|
||||||
|
shortcuts[key]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
delete_keys.append(key)
|
||||||
|
|
||||||
|
for key in delete_keys:
|
||||||
|
del config["shortcuts"][key]
|
||||||
|
|
||||||
|
LOG.info("Available shortcuts: {}".format(config["shortcuts"]))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_email_config(config, disable_validation=False):
|
||||||
|
"""function to simply ensure we can connect to email services.
|
||||||
|
|
||||||
|
This helps with failing early during startup.
|
||||||
|
"""
|
||||||
|
LOG.info("Checking IMAP configuration")
|
||||||
|
imap_server = _imap_connect()
|
||||||
|
LOG.info("Checking SMTP configuration")
|
||||||
|
smtp_server = _smtp_connect()
|
||||||
|
|
||||||
|
# Now validate and flag any shortcuts as invalid
|
||||||
|
if not disable_validation:
|
||||||
|
validate_shortcuts(config)
|
||||||
|
else:
|
||||||
|
LOG.info("Shortcuts email validation is Disabled!!, you were warned.")
|
||||||
|
|
||||||
|
if imap_server and smtp_server:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def parse_email(msgid, data, server):
|
||||||
|
envelope = data[b"ENVELOPE"]
|
||||||
|
# email address match
|
||||||
|
# use raw string to avoid invalid escape secquence errors r"string here"
|
||||||
|
f = re.search(r"([\.\w_-]+@[\.\w_-]+)", str(envelope.from_[0]))
|
||||||
|
if f is not None:
|
||||||
|
from_addr = f.group(1)
|
||||||
|
else:
|
||||||
|
from_addr = "noaddr"
|
||||||
|
LOG.debug("Got a message from '{}'".format(from_addr))
|
||||||
|
m = server.fetch([msgid], ["RFC822"])
|
||||||
|
msg = email.message_from_string(m[msgid][b"RFC822"].decode(errors="ignore"))
|
||||||
|
if msg.is_multipart():
|
||||||
|
text = ""
|
||||||
|
html = None
|
||||||
|
# default in case body somehow isn't set below - happened once
|
||||||
|
body = b"* unreadable msg received"
|
||||||
|
# this uses the last text or html part in the email, phone companies often put content in an attachment
|
||||||
|
for part in msg.get_payload():
|
||||||
|
if part.get_content_charset() is None:
|
||||||
|
# or BREAK when we hit a text or html?
|
||||||
|
# We cannot know the character set,
|
||||||
|
# so return decoded "something"
|
||||||
|
LOG.debug("Email got unknown content type")
|
||||||
|
text = part.get_payload(decode=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
charset = part.get_content_charset()
|
||||||
|
|
||||||
|
if part.get_content_type() == "text/plain":
|
||||||
|
LOG.debug("Email got text/plain")
|
||||||
|
text = six.text_type(
|
||||||
|
part.get_payload(decode=True), str(charset), "ignore"
|
||||||
|
).encode("utf8", "replace")
|
||||||
|
|
||||||
|
if part.get_content_type() == "text/html":
|
||||||
|
LOG.debug("Email got text/html")
|
||||||
|
html = six.text_type(
|
||||||
|
part.get_payload(decode=True), str(charset), "ignore"
|
||||||
|
).encode("utf8", "replace")
|
||||||
|
|
||||||
|
if text is not None:
|
||||||
|
# strip removes white space fore and aft of string
|
||||||
|
body = text.strip()
|
||||||
|
else:
|
||||||
|
body = html.strip()
|
||||||
|
else: # message is not multipart
|
||||||
|
# email.uscc.net sends no charset, blows up unicode function below
|
||||||
|
LOG.debug("Email is not multipart")
|
||||||
|
if msg.get_content_charset() is None:
|
||||||
|
text = six.text_type(
|
||||||
|
msg.get_payload(decode=True), "US-ASCII", "ignore"
|
||||||
|
).encode("utf8", "replace")
|
||||||
|
else:
|
||||||
|
text = six.text_type(
|
||||||
|
msg.get_payload(decode=True), msg.get_content_charset(), "ignore"
|
||||||
|
).encode("utf8", "replace")
|
||||||
|
body = text.strip()
|
||||||
|
|
||||||
|
# FIXED: UnicodeDecodeError: 'ascii' codec can't decode byte 0xf0 in position 6: ordinal not in range(128)
|
||||||
|
# decode with errors='ignore'. be sure to encode it before we return it below, also with errors='ignore'
|
||||||
|
try:
|
||||||
|
body = body.decode(errors="ignore")
|
||||||
|
except Exception as e:
|
||||||
|
LOG.error("Unicode decode failure: " + str(e))
|
||||||
|
LOG.error("Unidoce decode failed: " + str(body))
|
||||||
|
body = "Unreadable unicode msg"
|
||||||
|
# strip all html tags
|
||||||
|
body = re.sub("<[^<]+?>", "", body)
|
||||||
|
# strip CR/LF, make it one line, .rstrip fails at this
|
||||||
|
body = body.replace("\n", " ").replace("\r", " ")
|
||||||
|
# ascii might be out of range, so encode it, removing any error characters
|
||||||
|
body = body.encode(errors="ignore")
|
||||||
|
return (body, from_addr)
|
||||||
|
|
||||||
|
|
||||||
|
# end parse_email
|
||||||
|
|
||||||
|
|
||||||
|
def resend_email(count, fromcall):
|
||||||
|
global check_email_delay
|
||||||
|
date = datetime.datetime.now()
|
||||||
|
month = date.strftime("%B")[:3] # Nov, Mar, Apr
|
||||||
|
day = date.day
|
||||||
|
year = date.year
|
||||||
|
today = "%s-%s-%s" % (day, month, year)
|
||||||
|
|
||||||
|
shortcuts = CONFIG["shortcuts"]
|
||||||
|
# swap key/value
|
||||||
|
shortcuts_inverted = dict([[v, k] for k, v in shortcuts.items()])
|
||||||
|
|
||||||
|
try:
|
||||||
|
server = _imap_connect()
|
||||||
|
except Exception as e:
|
||||||
|
LOG.exception("Failed to Connect to IMAP. Cannot resend email ", e)
|
||||||
|
return
|
||||||
|
|
||||||
|
messages = server.search(["SINCE", today])
|
||||||
|
# LOG.debug("%d messages received today" % len(messages))
|
||||||
|
|
||||||
|
msgexists = False
|
||||||
|
|
||||||
|
messages.sort(reverse=True)
|
||||||
|
del messages[int(count) :] # only the latest "count" messages
|
||||||
|
for message in messages:
|
||||||
|
for msgid, data in list(server.fetch(message, ["ENVELOPE"]).items()):
|
||||||
|
# one at a time, otherwise order is random
|
||||||
|
(body, from_addr) = parse_email(msgid, data, server)
|
||||||
|
# unset seen flag, will stay bold in email client
|
||||||
|
server.remove_flags(msgid, [imapclient.SEEN])
|
||||||
|
if from_addr in shortcuts_inverted:
|
||||||
|
# reverse lookup of a shortcut
|
||||||
|
from_addr = shortcuts_inverted[from_addr]
|
||||||
|
# asterisk indicates a resend
|
||||||
|
reply = "-" + from_addr + " * " + body.decode(errors="ignore")
|
||||||
|
messaging.send_message(fromcall, reply)
|
||||||
|
msgexists = True
|
||||||
|
|
||||||
|
if msgexists is not True:
|
||||||
|
stm = time.localtime()
|
||||||
|
h = stm.tm_hour
|
||||||
|
m = stm.tm_min
|
||||||
|
s = stm.tm_sec
|
||||||
|
# append time as a kind of serial number to prevent FT1XDR from
|
||||||
|
# thinking this is a duplicate message.
|
||||||
|
# The FT1XDR pretty much ignores the aprs message number in this
|
||||||
|
# regard. The FTM400 gets it right.
|
||||||
|
reply = "No new msg %s:%s:%s" % (
|
||||||
|
str(h).zfill(2),
|
||||||
|
str(m).zfill(2),
|
||||||
|
str(s).zfill(2),
|
||||||
|
)
|
||||||
|
messaging.send_message(fromcall, reply)
|
||||||
|
|
||||||
|
# check email more often since we're resending one now
|
||||||
|
check_email_delay = 60
|
||||||
|
|
||||||
|
server.logout()
|
||||||
|
# end resend_email()
|
||||||
|
|
||||||
|
|
||||||
|
def check_email_thread():
|
||||||
|
global check_email_delay
|
||||||
|
|
||||||
|
# LOG.debug("FIXME initial email delay is 10 seconds")
|
||||||
|
check_email_delay = 60
|
||||||
|
while True:
|
||||||
|
# LOG.debug("Top of check_email_thread.")
|
||||||
|
|
||||||
|
time.sleep(check_email_delay)
|
||||||
|
|
||||||
|
# slowly increase delay every iteration, max out at 300 seconds
|
||||||
|
# any send/receive/resend activity will reset this to 60 seconds
|
||||||
|
if check_email_delay < 300:
|
||||||
|
check_email_delay += 1
|
||||||
|
LOG.debug("check_email_delay is " + str(check_email_delay) + " seconds")
|
||||||
|
|
||||||
|
shortcuts = CONFIG["shortcuts"]
|
||||||
|
# swap key/value
|
||||||
|
shortcuts_inverted = dict([[v, k] for k, v in shortcuts.items()])
|
||||||
|
|
||||||
|
date = datetime.datetime.now()
|
||||||
|
month = date.strftime("%B")[:3] # Nov, Mar, Apr
|
||||||
|
day = date.day
|
||||||
|
year = date.year
|
||||||
|
today = "%s-%s-%s" % (day, month, year)
|
||||||
|
|
||||||
|
server = None
|
||||||
|
try:
|
||||||
|
server = _imap_connect()
|
||||||
|
except Exception as e:
|
||||||
|
LOG.exception("Failed to get IMAP server Can't check email.", e)
|
||||||
|
|
||||||
|
if not server:
|
||||||
|
continue
|
||||||
|
|
||||||
|
messages = server.search(["SINCE", today])
|
||||||
|
# LOG.debug("{} messages received today".format(len(messages)))
|
||||||
|
|
||||||
|
for msgid, data in server.fetch(messages, ["ENVELOPE"]).items():
|
||||||
|
envelope = data[b"ENVELOPE"]
|
||||||
|
# LOG.debug('ID:%d "%s" (%s)' % (msgid, envelope.subject.decode(), envelope.date))
|
||||||
|
f = re.search(
|
||||||
|
r"'([[A-a][0-9]_-]+@[[A-a][0-9]_-\.]+)", str(envelope.from_[0])
|
||||||
|
)
|
||||||
|
if f is not None:
|
||||||
|
from_addr = f.group(1)
|
||||||
|
else:
|
||||||
|
from_addr = "noaddr"
|
||||||
|
|
||||||
|
# LOG.debug("Message flags/tags: " + str(server.get_flags(msgid)[msgid]))
|
||||||
|
# if "APRS" not in server.get_flags(msgid)[msgid]:
|
||||||
|
# in python3, imap tags are unicode. in py2 they're strings. so .decode them to handle both
|
||||||
|
taglist = [
|
||||||
|
x.decode(errors="ignore") for x in server.get_flags(msgid)[msgid]
|
||||||
|
]
|
||||||
|
if "APRS" not in taglist:
|
||||||
|
# if msg not flagged as sent via aprs
|
||||||
|
server.fetch([msgid], ["RFC822"])
|
||||||
|
(body, from_addr) = parse_email(msgid, data, server)
|
||||||
|
# unset seen flag, will stay bold in email client
|
||||||
|
server.remove_flags(msgid, [imapclient.SEEN])
|
||||||
|
|
||||||
|
if from_addr in shortcuts_inverted:
|
||||||
|
# reverse lookup of a shortcut
|
||||||
|
from_addr = shortcuts_inverted[from_addr]
|
||||||
|
|
||||||
|
reply = "-" + from_addr + " " + body.decode(errors="ignore")
|
||||||
|
messaging.send_message(CONFIG["ham"]["callsign"], reply)
|
||||||
|
# flag message as sent via aprs
|
||||||
|
server.add_flags(msgid, ["APRS"])
|
||||||
|
# unset seen flag, will stay bold in email client
|
||||||
|
server.remove_flags(msgid, [imapclient.SEEN])
|
||||||
|
# check email more often since we just received an email
|
||||||
|
check_email_delay = 60
|
||||||
|
|
||||||
|
server.logout()
|
||||||
|
|
||||||
|
|
||||||
|
# end check_email()
|
||||||
|
|
||||||
|
|
||||||
|
def send_email(to_addr, content):
|
||||||
|
global check_email_delay
|
||||||
|
|
||||||
|
LOG.info("Sending Email_________________")
|
||||||
|
shortcuts = CONFIG["shortcuts"]
|
||||||
|
if to_addr in shortcuts:
|
||||||
|
LOG.info("To : " + to_addr)
|
||||||
|
to_addr = shortcuts[to_addr]
|
||||||
|
LOG.info(" (" + to_addr + ")")
|
||||||
|
subject = CONFIG["ham"]["callsign"]
|
||||||
|
# content = content + "\n\n(NOTE: reply with one line)"
|
||||||
|
LOG.info("Subject : " + subject)
|
||||||
|
LOG.info("Body : " + content)
|
||||||
|
|
||||||
|
# check email more often since there's activity right now
|
||||||
|
check_email_delay = 60
|
||||||
|
|
||||||
|
msg = MIMEText(content)
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = CONFIG["smtp"]["login"]
|
||||||
|
msg["To"] = to_addr
|
||||||
|
server = _smtp_connect()
|
||||||
|
if server:
|
||||||
|
try:
|
||||||
|
server.sendmail(CONFIG["smtp"]["login"], [to_addr], msg.as_string())
|
||||||
|
except Exception as e:
|
||||||
|
msg = getattr(e, "message", repr(e))
|
||||||
|
LOG.error("Sendmail Error!!!! '{}'", msg)
|
||||||
|
server.quit()
|
||||||
|
return -1
|
||||||
|
server.quit()
|
||||||
|
return 0
|
||||||
|
# end send_email
|
945
aprsd/main.py
945
aprsd/main.py
File diff suppressed because it is too large
Load Diff
247
aprsd/messaging.py
Normal file
247
aprsd/messaging.py
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
import logging
|
||||||
|
import pprint
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from aprsd import client
|
||||||
|
|
||||||
|
LOG = logging.getLogger("APRSD")
|
||||||
|
CONFIG = None
|
||||||
|
|
||||||
|
# current aprs radio message number, increments for each message we
|
||||||
|
# send over rf {int}
|
||||||
|
message_number = 0
|
||||||
|
|
||||||
|
# message_nubmer:ack combos so we stop sending a message after an
|
||||||
|
# ack from radio {int:int}
|
||||||
|
ack_dict = {}
|
||||||
|
|
||||||
|
# What to return from a plugin if we have processed the message
|
||||||
|
# and it's ok, but don't send a usage string back
|
||||||
|
NULL_MESSAGE = -1
|
||||||
|
|
||||||
|
|
||||||
|
def send_ack_thread(tocall, ack, retry_count):
|
||||||
|
cl = client.get_client()
|
||||||
|
tocall = tocall.ljust(9) # pad to nine chars
|
||||||
|
line = "{}>APRS::{}:ack{}\n".format(CONFIG["aprs"]["login"], tocall, ack)
|
||||||
|
for i in range(retry_count, 0, -1):
|
||||||
|
log_message(
|
||||||
|
"Sending ack",
|
||||||
|
line.rstrip("\n"),
|
||||||
|
None,
|
||||||
|
ack=ack,
|
||||||
|
tocall=tocall,
|
||||||
|
retry_number=i,
|
||||||
|
)
|
||||||
|
cl.sendall(line)
|
||||||
|
# aprs duplicate detection is 30 secs?
|
||||||
|
# (21 only sends first, 28 skips middle)
|
||||||
|
time.sleep(31)
|
||||||
|
# end_send_ack_thread
|
||||||
|
|
||||||
|
|
||||||
|
def send_ack(tocall, ack):
|
||||||
|
LOG.debug("Send ACK({}:{}) to radio.".format(tocall, ack))
|
||||||
|
retry_count = 3
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=send_ack_thread, name="send_ack", args=(tocall, ack, retry_count)
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
# end send_ack()
|
||||||
|
|
||||||
|
|
||||||
|
def send_message_thread(tocall, message, this_message_number, retry_count):
|
||||||
|
cl = client.get_client()
|
||||||
|
line = "{}>APRS::{}:{}{{{}\n".format(
|
||||||
|
CONFIG["aprs"]["login"],
|
||||||
|
tocall,
|
||||||
|
message,
|
||||||
|
str(this_message_number),
|
||||||
|
)
|
||||||
|
for i in range(retry_count, 0, -1):
|
||||||
|
LOG.debug("DEBUG: send_message_thread msg:ack combos are: ")
|
||||||
|
LOG.debug(pprint.pformat(ack_dict))
|
||||||
|
if ack_dict[this_message_number] != 1:
|
||||||
|
log_message(
|
||||||
|
"Sending Message",
|
||||||
|
line.rstrip("\n"),
|
||||||
|
message,
|
||||||
|
tocall=tocall,
|
||||||
|
retry_number=i,
|
||||||
|
)
|
||||||
|
# tn.write(line)
|
||||||
|
cl.sendall(line)
|
||||||
|
# decaying repeats, 31 to 93 second intervals
|
||||||
|
sleeptime = (retry_count - i + 1) * 31
|
||||||
|
time.sleep(sleeptime)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return
|
||||||
|
# end send_message_thread
|
||||||
|
|
||||||
|
|
||||||
|
def send_message(tocall, message):
|
||||||
|
global message_number
|
||||||
|
global ack_dict
|
||||||
|
|
||||||
|
retry_count = 3
|
||||||
|
if message_number > 98: # global
|
||||||
|
message_number = 0
|
||||||
|
message_number += 1
|
||||||
|
if len(ack_dict) > 90:
|
||||||
|
# empty ack dict if it's really big, could result in key error later
|
||||||
|
LOG.debug(
|
||||||
|
"DEBUG: Length of ack dictionary is big at %s clearing." % len(ack_dict)
|
||||||
|
)
|
||||||
|
ack_dict.clear()
|
||||||
|
LOG.debug(pprint.pformat(ack_dict))
|
||||||
|
LOG.debug(
|
||||||
|
"DEBUG: Cleared ack dictionary, ack_dict length is now %s." % len(ack_dict)
|
||||||
|
)
|
||||||
|
ack_dict[message_number] = 0 # clear ack for this message number
|
||||||
|
tocall = tocall.ljust(9) # pad to nine chars
|
||||||
|
|
||||||
|
# max? ftm400 displays 64, raw msg shows 74
|
||||||
|
# and ftm400-send is max 64. setting this to
|
||||||
|
# 67 displays 64 on the ftm400. (+3 {01 suffix)
|
||||||
|
# feature req: break long ones into two msgs
|
||||||
|
message = message[:67]
|
||||||
|
# We all miss George Carlin
|
||||||
|
message = re.sub("fuck|shit|cunt|piss|cock|bitch", "****", message)
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=send_message_thread,
|
||||||
|
name="send_message",
|
||||||
|
args=(tocall, message, message_number, retry_count),
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
return ()
|
||||||
|
# end send_message()
|
||||||
|
|
||||||
|
|
||||||
|
def log_packet(packet):
|
||||||
|
fromcall = packet.get("from", None)
|
||||||
|
tocall = packet.get("to", None)
|
||||||
|
|
||||||
|
response_type = packet.get("response", None)
|
||||||
|
msg = packet.get("message_text", None)
|
||||||
|
msg_num = packet.get("msgNo", None)
|
||||||
|
ack = packet.get("ack", None)
|
||||||
|
|
||||||
|
log_message(
|
||||||
|
"Packet",
|
||||||
|
packet["raw"],
|
||||||
|
msg,
|
||||||
|
fromcall=fromcall,
|
||||||
|
tocall=tocall,
|
||||||
|
ack=ack,
|
||||||
|
packet_type=response_type,
|
||||||
|
msg_num=msg_num,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def log_message(
|
||||||
|
header,
|
||||||
|
raw,
|
||||||
|
message,
|
||||||
|
tocall=None,
|
||||||
|
fromcall=None,
|
||||||
|
msg_num=None,
|
||||||
|
retry_number=None,
|
||||||
|
ack=None,
|
||||||
|
packet_type=None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
|
||||||
|
Log a message entry.
|
||||||
|
|
||||||
|
This builds a long string with newlines for the log entry, so that
|
||||||
|
it's thread safe. If we log each item as a separate log.debug() call
|
||||||
|
Then the message information could get multiplexed with other log
|
||||||
|
messages. Each python log call is automatically synchronized.
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
log_list = [""]
|
||||||
|
if retry_number:
|
||||||
|
# LOG.info(" {} _______________(TX:{})".format(header, retry_number))
|
||||||
|
log_list.append(" {} _______________(TX:{})".format(header, retry_number))
|
||||||
|
else:
|
||||||
|
# LOG.info(" {} _______________".format(header))
|
||||||
|
log_list.append(" {} _______________".format(header))
|
||||||
|
|
||||||
|
# LOG.info(" Raw : {}".format(raw))
|
||||||
|
log_list.append(" Raw : {}".format(raw))
|
||||||
|
|
||||||
|
if packet_type:
|
||||||
|
# LOG.info(" Packet : {}".format(packet_type))
|
||||||
|
log_list.append(" Packet : {}".format(packet_type))
|
||||||
|
if tocall:
|
||||||
|
# LOG.info(" To : {}".format(tocall))
|
||||||
|
log_list.append(" To : {}".format(tocall))
|
||||||
|
if fromcall:
|
||||||
|
# LOG.info(" From : {}".format(fromcall))
|
||||||
|
log_list.append(" From : {}".format(fromcall))
|
||||||
|
|
||||||
|
if ack:
|
||||||
|
# LOG.info(" Ack : {}".format(ack))
|
||||||
|
log_list.append(" Ack : {}".format(ack))
|
||||||
|
else:
|
||||||
|
# LOG.info(" Message : {}".format(message))
|
||||||
|
log_list.append(" Message : {}".format(message))
|
||||||
|
if msg_num:
|
||||||
|
# LOG.info(" Msg number : {}".format(msg_num))
|
||||||
|
log_list.append(" Msg number : {}".format(msg_num))
|
||||||
|
# LOG.info(" {} _______________ Complete".format(header))
|
||||||
|
log_list.append(" {} _______________ Complete".format(header))
|
||||||
|
|
||||||
|
LOG.info("\n".join(log_list))
|
||||||
|
|
||||||
|
|
||||||
|
def send_message_direct(tocall, message):
|
||||||
|
"""Send a message without a separate thread."""
|
||||||
|
cl = client.get_client()
|
||||||
|
this_message_number = 1
|
||||||
|
fromcall = CONFIG["aprs"]["login"]
|
||||||
|
line = "{}>APRS::{}:{}{{{}\n".format(
|
||||||
|
fromcall,
|
||||||
|
tocall,
|
||||||
|
message,
|
||||||
|
str(this_message_number),
|
||||||
|
)
|
||||||
|
LOG.debug("DEBUG: send_message_thread msg:ack combos are: ")
|
||||||
|
log_message(
|
||||||
|
"Sending Message", line.rstrip("\n"), message, tocall=tocall, fromcall=fromcall
|
||||||
|
)
|
||||||
|
cl.sendall(line)
|
||||||
|
|
||||||
|
|
||||||
|
def process_message(line):
|
||||||
|
f = re.search("^(.*)>", line)
|
||||||
|
fromcall = f.group(1)
|
||||||
|
searchstring = "::%s[ ]*:(.*)" % CONFIG["aprs"]["login"]
|
||||||
|
# verify this, callsign is padded out with spaces to colon
|
||||||
|
m = re.search(searchstring, line)
|
||||||
|
fullmessage = m.group(1)
|
||||||
|
|
||||||
|
ack_attached = re.search("(.*){([0-9A-Z]+)", fullmessage)
|
||||||
|
# ack formats include: {1, {AB}, {12
|
||||||
|
if ack_attached:
|
||||||
|
# "{##" suffix means radio wants an ack back
|
||||||
|
# message content
|
||||||
|
message = ack_attached.group(1)
|
||||||
|
# suffix number to use in ack
|
||||||
|
ack_num = ack_attached.group(2)
|
||||||
|
else:
|
||||||
|
message = fullmessage
|
||||||
|
# ack not requested, but lets send one as 0
|
||||||
|
ack_num = "0"
|
||||||
|
|
||||||
|
log_message(
|
||||||
|
"Received message", line, message, fromcall=fromcall, msg_num=str(ack_num)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (fromcall, message, ack_num)
|
||||||
|
# end process_message()
|
298
aprsd/plugin.py
298
aprsd/plugin.py
@ -1,6 +1,7 @@
|
|||||||
# The base plugin class
|
# The base plugin class
|
||||||
import abc
|
import abc
|
||||||
import fnmatch
|
import fnmatch
|
||||||
|
import importlib
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@ -14,6 +15,7 @@ import requests
|
|||||||
import six
|
import six
|
||||||
from thesmuggler import smuggle
|
from thesmuggler import smuggle
|
||||||
|
|
||||||
|
from aprsd import email, messaging
|
||||||
from aprsd.fuzzyclock import fuzzy
|
from aprsd.fuzzyclock import fuzzy
|
||||||
|
|
||||||
# setup the global logger
|
# setup the global logger
|
||||||
@ -23,81 +25,42 @@ hookspec = pluggy.HookspecMarker("aprsd")
|
|||||||
hookimpl = pluggy.HookimplMarker("aprsd")
|
hookimpl = pluggy.HookimplMarker("aprsd")
|
||||||
|
|
||||||
CORE_PLUGINS = [
|
CORE_PLUGINS = [
|
||||||
"FortunePlugin",
|
"aprsd.plugin.EmailPlugin",
|
||||||
"LocationPlugin",
|
"aprsd.plugin.FortunePlugin",
|
||||||
"PingPlugin",
|
"aprsd.plugin.LocationPlugin",
|
||||||
"TimePlugin",
|
"aprsd.plugin.PingPlugin",
|
||||||
"WeatherPlugin",
|
"aprsd.plugin.TimePlugin",
|
||||||
|
"aprsd.plugin.WeatherPlugin",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def setup_plugins(config):
|
|
||||||
"""Create the plugin manager and register plugins."""
|
|
||||||
|
|
||||||
LOG.info("Loading Core APRSD Command Plugins")
|
|
||||||
enabled_plugins = config["aprsd"].get("enabled_plugins", None)
|
|
||||||
pm = pluggy.PluginManager("aprsd")
|
|
||||||
pm.add_hookspecs(APRSDCommandSpec)
|
|
||||||
for p_name in CORE_PLUGINS:
|
|
||||||
plugin_obj = None
|
|
||||||
if enabled_plugins:
|
|
||||||
if p_name in enabled_plugins:
|
|
||||||
plugin_obj = globals()[p_name](config)
|
|
||||||
else:
|
|
||||||
# Enabled plugins isn't set, so we default to loading all of
|
|
||||||
# the core plugins.
|
|
||||||
plugin_obj = globals()[p_name](config)
|
|
||||||
|
|
||||||
if plugin_obj:
|
|
||||||
LOG.info(
|
|
||||||
"Registering Command plugin '{}'({}) '{}'".format(
|
|
||||||
p_name, plugin_obj.version, plugin_obj.command_regex
|
|
||||||
)
|
|
||||||
)
|
|
||||||
pm.register(plugin_obj)
|
|
||||||
|
|
||||||
plugin_dir = config["aprsd"].get("plugin_dir", None)
|
|
||||||
if plugin_dir:
|
|
||||||
LOG.info("Trying to load custom plugins from '{}'".format(plugin_dir))
|
|
||||||
cpm = PluginManager(config)
|
|
||||||
plugins_list = cpm.load_plugins(plugin_dir)
|
|
||||||
LOG.info("Discovered {} modules to load".format(len(plugins_list)))
|
|
||||||
for o in plugins_list:
|
|
||||||
plugin_obj = None
|
|
||||||
if enabled_plugins:
|
|
||||||
if o["name"] in enabled_plugins:
|
|
||||||
plugin_obj = o["obj"]
|
|
||||||
else:
|
|
||||||
LOG.info(
|
|
||||||
"'{}' plugin not listed in config aprsd:enabled_plugins".format(
|
|
||||||
o["name"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# not setting enabled plugins means load all?
|
|
||||||
plugin_obj = o["obj"]
|
|
||||||
|
|
||||||
if plugin_obj:
|
|
||||||
LOG.info(
|
|
||||||
"Registering Command plugin '{}'({}) '{}'".format(
|
|
||||||
o["name"], o["obj"].version, o["obj"].command_regex
|
|
||||||
)
|
|
||||||
)
|
|
||||||
pm.register(o["obj"])
|
|
||||||
|
|
||||||
else:
|
|
||||||
LOG.info("Skipping Custom Plugins.")
|
|
||||||
|
|
||||||
LOG.info("Completed Plugin Loading.")
|
|
||||||
return pm
|
|
||||||
|
|
||||||
|
|
||||||
class PluginManager(object):
|
class PluginManager(object):
|
||||||
def __init__(self, config):
|
# The singleton instance object for this class
|
||||||
self.obj_list = []
|
_instance = None
|
||||||
self.config = config
|
|
||||||
|
# the pluggy PluginManager
|
||||||
|
_pluggy_pm = None
|
||||||
|
|
||||||
|
# aprsd config dict
|
||||||
|
config = None
|
||||||
|
|
||||||
|
def __new__(cls, *args, **kwargs):
|
||||||
|
"""This magic turns this into a singleton."""
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super(PluginManager, cls).__new__(cls)
|
||||||
|
# Put any initialization here.
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self, config=None):
|
||||||
|
self.obj_list = []
|
||||||
|
if config:
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def load_plugins_from_path(self, module_path):
|
||||||
|
if not os.path.exists(module_path):
|
||||||
|
LOG.error("plugin path '{}' doesn't exist.".format(module_path))
|
||||||
|
return None
|
||||||
|
|
||||||
def load_plugins(self, module_path):
|
|
||||||
dir_path = os.path.realpath(module_path)
|
dir_path = os.path.realpath(module_path)
|
||||||
pattern = "*.py"
|
pattern = "*.py"
|
||||||
|
|
||||||
@ -123,6 +86,108 @@ class PluginManager(object):
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _create_class(self, module_class_string, super_cls: type = None, **kwargs):
|
||||||
|
"""
|
||||||
|
Method to create a class from a fqn python string.
|
||||||
|
|
||||||
|
:param module_class_string: full name of the class to create an object of
|
||||||
|
:param super_cls: expected super class for validity, None if bypass
|
||||||
|
:param kwargs: parameters to pass
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
module_name, class_name = module_class_string.rsplit(".", 1)
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.error("Failed to load Plugin '{}' : '{}'".format(module_name, ex))
|
||||||
|
return
|
||||||
|
|
||||||
|
assert hasattr(module, class_name), "class {} is not in {}".format(
|
||||||
|
class_name, module_name
|
||||||
|
)
|
||||||
|
# click.echo('reading class {} from module {}'.format(
|
||||||
|
# class_name, module_name))
|
||||||
|
cls = getattr(module, class_name)
|
||||||
|
if super_cls is not None:
|
||||||
|
assert issubclass(cls, super_cls), "class {} should inherit from {}".format(
|
||||||
|
class_name, super_cls.__name__
|
||||||
|
)
|
||||||
|
# click.echo('initialising {} with params {}'.format(class_name, kwargs))
|
||||||
|
obj = cls(**kwargs)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def _load_plugin(self, plugin_name):
|
||||||
|
"""
|
||||||
|
|
||||||
|
Given a python fully qualified class path.name,
|
||||||
|
Try importing the path, then creating the object,
|
||||||
|
then registering it as a aprsd Command Plugin
|
||||||
|
"""
|
||||||
|
plugin_obj = None
|
||||||
|
try:
|
||||||
|
plugin_obj = self._create_class(
|
||||||
|
plugin_name, APRSDPluginBase, config=self.config
|
||||||
|
)
|
||||||
|
if plugin_obj:
|
||||||
|
LOG.info(
|
||||||
|
"Registering Command plugin '{}'({}) '{}'".format(
|
||||||
|
plugin_name, plugin_obj.version, plugin_obj.command_regex
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._pluggy_pm.register(plugin_obj)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.exception("Couldn't load plugin '{}'".format(plugin_name), ex)
|
||||||
|
|
||||||
|
def setup_plugins(self):
|
||||||
|
"""Create the plugin manager and register plugins."""
|
||||||
|
|
||||||
|
LOG.info("Loading Core APRSD Command Plugins")
|
||||||
|
enabled_plugins = self.config["aprsd"].get("enabled_plugins", None)
|
||||||
|
self._pluggy_pm = pluggy.PluginManager("aprsd")
|
||||||
|
self._pluggy_pm.add_hookspecs(APRSDCommandSpec)
|
||||||
|
if enabled_plugins:
|
||||||
|
for p_name in enabled_plugins:
|
||||||
|
self._load_plugin(p_name)
|
||||||
|
else:
|
||||||
|
# Enabled plugins isn't set, so we default to loading all of
|
||||||
|
# the core plugins.
|
||||||
|
for p_name in CORE_PLUGINS:
|
||||||
|
self._load_plugin(p_name)
|
||||||
|
|
||||||
|
plugin_dir = self.config["aprsd"].get("plugin_dir", None)
|
||||||
|
if plugin_dir:
|
||||||
|
LOG.info("Trying to load custom plugins from '{}'".format(plugin_dir))
|
||||||
|
plugins_list = self.load_plugins_from_path(plugin_dir)
|
||||||
|
if plugins_list:
|
||||||
|
LOG.info("Discovered {} modules to load".format(len(plugins_list)))
|
||||||
|
for o in plugins_list:
|
||||||
|
plugin_obj = None
|
||||||
|
# not setting enabled plugins means load all?
|
||||||
|
plugin_obj = o["obj"]
|
||||||
|
|
||||||
|
if plugin_obj:
|
||||||
|
LOG.info(
|
||||||
|
"Registering Command plugin '{}'({}) '{}'".format(
|
||||||
|
o["name"], o["obj"].version, o["obj"].command_regex
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._pluggy_pm.register(o["obj"])
|
||||||
|
|
||||||
|
else:
|
||||||
|
LOG.info("Skipping Custom Plugins directory.")
|
||||||
|
LOG.info("Completed Plugin Loading.")
|
||||||
|
|
||||||
|
def run(self, *args, **kwargs):
|
||||||
|
"""Execute all the pluguns run method."""
|
||||||
|
return self._pluggy_pm.hook.run(*args, **kwargs)
|
||||||
|
|
||||||
|
def register(self, obj):
|
||||||
|
"""Register the plugin."""
|
||||||
|
self._pluggy_pm.register(obj)
|
||||||
|
|
||||||
|
def get_plugins(self):
|
||||||
|
return self._pluggy_pm.get_plugins()
|
||||||
|
|
||||||
|
|
||||||
class APRSDCommandSpec:
|
class APRSDCommandSpec:
|
||||||
"""A hook specification namespace."""
|
"""A hook specification namespace."""
|
||||||
@ -184,7 +249,6 @@ class FortunePlugin(APRSDPluginBase):
|
|||||||
["/usr/games/fortune", "-s", "-n 60"], stdout=subprocess.PIPE
|
["/usr/games/fortune", "-s", "-n 60"], stdout=subprocess.PIPE
|
||||||
)
|
)
|
||||||
reply = process.communicate()[0]
|
reply = process.communicate()[0]
|
||||||
# send_message(fromcall, reply.rstrip())
|
|
||||||
reply = reply.decode(errors="ignore").rstrip()
|
reply = reply.decode(errors="ignore").rstrip()
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
reply = "Fortune command failed '{}'".format(ex)
|
reply = "Fortune command failed '{}'".format(ex)
|
||||||
@ -200,20 +264,20 @@ class LocationPlugin(APRSDPluginBase):
|
|||||||
command_regex = "^[lL]"
|
command_regex = "^[lL]"
|
||||||
command_name = "location"
|
command_name = "location"
|
||||||
|
|
||||||
|
config_items = {"apikey": "aprs.fi api key here"}
|
||||||
|
|
||||||
def command(self, fromcall, message, ack):
|
def command(self, fromcall, message, ack):
|
||||||
LOG.info("Location Plugin")
|
LOG.info("Location Plugin")
|
||||||
# get last location of a callsign, get descriptive name from weather service
|
# get last location of a callsign, get descriptive name from weather service
|
||||||
try:
|
try:
|
||||||
a = re.search(
|
# optional second argument is a callsign to search
|
||||||
r"^.*\s+(.*)", message
|
a = re.search(r"^.*\s+(.*)", message)
|
||||||
) # optional second argument is a callsign to search
|
|
||||||
if a is not None:
|
if a is not None:
|
||||||
searchcall = a.group(1)
|
searchcall = a.group(1)
|
||||||
searchcall = searchcall.upper()
|
searchcall = searchcall.upper()
|
||||||
else:
|
else:
|
||||||
searchcall = (
|
# if no second argument, search for calling station
|
||||||
fromcall # if no second argument, search for calling station
|
searchcall = fromcall
|
||||||
)
|
|
||||||
url = (
|
url = (
|
||||||
"http://api.aprs.fi/api/get?name="
|
"http://api.aprs.fi/api/get?name="
|
||||||
+ searchcall
|
+ searchcall
|
||||||
@ -222,6 +286,7 @@ class LocationPlugin(APRSDPluginBase):
|
|||||||
response = requests.get(url)
|
response = requests.get(url)
|
||||||
# aprs_data = json.loads(response.read())
|
# aprs_data = json.loads(response.read())
|
||||||
aprs_data = json.loads(response.text)
|
aprs_data = json.loads(response.text)
|
||||||
|
LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data))
|
||||||
lat = aprs_data["entries"][0]["lat"]
|
lat = aprs_data["entries"][0]["lat"]
|
||||||
lon = aprs_data["entries"][0]["lng"]
|
lon = aprs_data["entries"][0]["lng"]
|
||||||
try: # altitude not always provided
|
try: # altitude not always provided
|
||||||
@ -230,9 +295,9 @@ class LocationPlugin(APRSDPluginBase):
|
|||||||
alt = 0
|
alt = 0
|
||||||
altfeet = int(alt * 3.28084)
|
altfeet = int(alt * 3.28084)
|
||||||
aprs_lasttime_seconds = aprs_data["entries"][0]["lasttime"]
|
aprs_lasttime_seconds = aprs_data["entries"][0]["lasttime"]
|
||||||
aprs_lasttime_seconds = aprs_lasttime_seconds.encode(
|
# aprs_lasttime_seconds = aprs_lasttime_seconds.encode(
|
||||||
"ascii", errors="ignore"
|
# "ascii", errors="ignore"
|
||||||
) # unicode to ascii
|
# ) # unicode to ascii
|
||||||
delta_seconds = time.time() - int(aprs_lasttime_seconds)
|
delta_seconds = time.time() - int(aprs_lasttime_seconds)
|
||||||
delta_hours = delta_seconds / 60 / 60
|
delta_hours = delta_seconds / 60 / 60
|
||||||
url2 = (
|
url2 = (
|
||||||
@ -342,3 +407,76 @@ class WeatherPlugin(APRSDPluginBase):
|
|||||||
reply = "Unable to find you (send beacon?)"
|
reply = "Unable to find you (send beacon?)"
|
||||||
|
|
||||||
return reply
|
return reply
|
||||||
|
|
||||||
|
|
||||||
|
class EmailPlugin(APRSDPluginBase):
|
||||||
|
"""Email Plugin."""
|
||||||
|
|
||||||
|
version = "1.0"
|
||||||
|
command_regex = "^-.*"
|
||||||
|
command_name = "email"
|
||||||
|
|
||||||
|
# message_number:time combos so we don't resend the same email in
|
||||||
|
# five mins {int:int}
|
||||||
|
email_sent_dict = {}
|
||||||
|
|
||||||
|
def command(self, fromcall, message, ack):
|
||||||
|
LOG.info("Email COMMAND")
|
||||||
|
reply = None
|
||||||
|
|
||||||
|
searchstring = "^" + self.config["ham"]["callsign"] + ".*"
|
||||||
|
# only I can do email
|
||||||
|
if re.search(searchstring, fromcall):
|
||||||
|
# digits only, first one is number of emails to resend
|
||||||
|
r = re.search("^-([0-9])[0-9]*$", message)
|
||||||
|
if r is not None:
|
||||||
|
LOG.debug("RESEND EMAIL")
|
||||||
|
email.resend_email(r.group(1), fromcall)
|
||||||
|
reply = messaging.NULL_MESSAGE
|
||||||
|
# -user@address.com body of email
|
||||||
|
elif re.search(r"^-([A-Za-z0-9_\-\.@]+) (.*)", message):
|
||||||
|
# (same search again)
|
||||||
|
a = re.search(r"^-([A-Za-z0-9_\-\.@]+) (.*)", message)
|
||||||
|
if a is not None:
|
||||||
|
to_addr = a.group(1)
|
||||||
|
content = a.group(2)
|
||||||
|
# send recipient link to aprs.fi map
|
||||||
|
if content == "mapme":
|
||||||
|
content = "Click for my location: http://aprs.fi/{}".format(
|
||||||
|
self.config["ham"]["callsign"]
|
||||||
|
)
|
||||||
|
too_soon = 0
|
||||||
|
now = time.time()
|
||||||
|
# see if we sent this msg number recently
|
||||||
|
if ack in self.email_sent_dict:
|
||||||
|
timedelta = now - self.email_sent_dict[ack]
|
||||||
|
if timedelta < 300: # five minutes
|
||||||
|
too_soon = 1
|
||||||
|
if not too_soon or ack == 0:
|
||||||
|
LOG.info("Send email '{}'".format(content))
|
||||||
|
send_result = email.send_email(to_addr, content)
|
||||||
|
if send_result != 0:
|
||||||
|
reply = "-{} failed".format(to_addr)
|
||||||
|
# messaging.send_message(fromcall, "-" + to_addr + " failed")
|
||||||
|
else:
|
||||||
|
# clear email sent dictionary if somehow goes over 100
|
||||||
|
if len(self.email_sent_dict) > 98:
|
||||||
|
LOG.debug(
|
||||||
|
"DEBUG: email_sent_dict is big ("
|
||||||
|
+ str(len(self.email_sent_dict))
|
||||||
|
+ ") clearing out."
|
||||||
|
)
|
||||||
|
self.email_sent_dict.clear()
|
||||||
|
self.email_sent_dict[ack] = now
|
||||||
|
reply = "mapme email sent"
|
||||||
|
else:
|
||||||
|
LOG.info(
|
||||||
|
"Email for message number "
|
||||||
|
+ ack
|
||||||
|
+ " recently sent, not sending again."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
reply = "Bad email address"
|
||||||
|
# messaging.send_message(fromcall, "Bad email address")
|
||||||
|
|
||||||
|
return reply
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
"""Utilities and helper functions."""
|
"""Utilities and helper functions."""
|
||||||
|
|
||||||
import errno
|
import errno
|
||||||
|
import functools
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import yaml
|
import yaml
|
||||||
@ -15,7 +17,7 @@ DEFAULT_CONFIG_DICT = {
|
|||||||
"aprs": {
|
"aprs": {
|
||||||
"login": "someusername",
|
"login": "someusername",
|
||||||
"password": "somepassword",
|
"password": "somepassword",
|
||||||
"host": "noam.aprs2.net",
|
"host": "rotate.aprs.net",
|
||||||
"port": 14580,
|
"port": 14580,
|
||||||
"logfile": "/tmp/arsd.log",
|
"logfile": "/tmp/arsd.log",
|
||||||
},
|
},
|
||||||
@ -47,6 +49,17 @@ DEFAULT_CONFIG_DICT = {
|
|||||||
DEFAULT_CONFIG_FILE = "~/.config/aprsd/aprsd.yml"
|
DEFAULT_CONFIG_FILE = "~/.config/aprsd/aprsd.yml"
|
||||||
|
|
||||||
|
|
||||||
|
def synchronized(wrapped):
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
@functools.wraps(wrapped)
|
||||||
|
def _wrap(*args, **kwargs):
|
||||||
|
with lock:
|
||||||
|
return wrapped(*args, **kwargs)
|
||||||
|
|
||||||
|
return _wrap
|
||||||
|
|
||||||
|
|
||||||
def env(*vars, **kwargs):
|
def env(*vars, **kwargs):
|
||||||
"""This returns the first environment variable set.
|
"""This returns the first environment variable set.
|
||||||
if none are non-empty, defaults to '' or keyword arg default
|
if none are non-empty, defaults to '' or keyword arg default
|
||||||
@ -152,8 +165,8 @@ def parse_config(config_file):
|
|||||||
)
|
)
|
||||||
check_option(config, "aprs", "login")
|
check_option(config, "aprs", "login")
|
||||||
check_option(config, "aprs", "password")
|
check_option(config, "aprs", "password")
|
||||||
check_option(config, "aprs", "host")
|
# check_option(config, "aprs", "host")
|
||||||
check_option(config, "aprs", "port")
|
# check_option(config, "aprs", "port")
|
||||||
check_option(config, "aprs", "logfile", "./aprsd.log")
|
check_option(config, "aprs", "logfile", "./aprsd.log")
|
||||||
check_option(config, "imap", "host")
|
check_option(config, "imap", "host")
|
||||||
check_option(config, "imap", "login")
|
check_option(config, "imap", "login")
|
||||||
|
@ -7,4 +7,3 @@ pep8-naming
|
|||||||
black
|
black
|
||||||
isort
|
isort
|
||||||
Sphinx
|
Sphinx
|
||||||
thesmuggler
|
|
||||||
|
@ -10,7 +10,7 @@ attrs==20.3.0 # via pytest
|
|||||||
babel==2.9.0 # via sphinx
|
babel==2.9.0 # via sphinx
|
||||||
black==20.8b1 # via -r dev-requirements.in
|
black==20.8b1 # via -r dev-requirements.in
|
||||||
certifi==2020.12.5 # via requests
|
certifi==2020.12.5 # via requests
|
||||||
chardet==3.0.4 # via requests
|
chardet==4.0.0 # via requests
|
||||||
click==7.1.2 # via black
|
click==7.1.2 # via black
|
||||||
coverage==5.3 # via pytest-cov
|
coverage==5.3 # via pytest-cov
|
||||||
distlib==0.3.1 # via virtualenv
|
distlib==0.3.1 # via virtualenv
|
||||||
@ -37,10 +37,10 @@ pyflakes==2.2.0 # via flake8
|
|||||||
pygments==2.7.3 # via sphinx
|
pygments==2.7.3 # via sphinx
|
||||||
pyparsing==2.4.7 # via packaging
|
pyparsing==2.4.7 # via packaging
|
||||||
pytest-cov==2.10.1 # via -r dev-requirements.in
|
pytest-cov==2.10.1 # via -r dev-requirements.in
|
||||||
pytest==6.2.0 # via -r dev-requirements.in, pytest-cov
|
pytest==6.2.1 # via -r dev-requirements.in, pytest-cov
|
||||||
pytz==2020.4 # via babel
|
pytz==2020.4 # via babel
|
||||||
regex==2020.11.13 # via black
|
regex==2020.11.13 # via black
|
||||||
requests==2.25.0 # via sphinx
|
requests==2.25.1 # via sphinx
|
||||||
six==1.15.0 # via tox, virtualenv
|
six==1.15.0 # via tox, virtualenv
|
||||||
snowballstemmer==2.0.0 # via sphinx
|
snowballstemmer==2.0.0 # via sphinx
|
||||||
sphinx==3.3.1 # via -r dev-requirements.in
|
sphinx==3.3.1 # via -r dev-requirements.in
|
||||||
@ -50,7 +50,6 @@ sphinxcontrib-htmlhelp==1.0.3 # via sphinx
|
|||||||
sphinxcontrib-jsmath==1.0.1 # via sphinx
|
sphinxcontrib-jsmath==1.0.1 # via sphinx
|
||||||
sphinxcontrib-qthelp==1.0.3 # via sphinx
|
sphinxcontrib-qthelp==1.0.3 # via sphinx
|
||||||
sphinxcontrib-serializinghtml==1.1.4 # via sphinx
|
sphinxcontrib-serializinghtml==1.1.4 # via sphinx
|
||||||
thesmuggler==1.0.1 # via -r dev-requirements.in
|
|
||||||
toml==0.10.2 # via black, pytest, tox
|
toml==0.10.2 # via black, pytest, tox
|
||||||
tox==3.20.1 # via -r dev-requirements.in
|
tox==3.20.1 # via -r dev-requirements.in
|
||||||
typed-ast==1.4.1 # via black, mypy
|
typed-ast==1.4.1 # via black, mypy
|
||||||
|
@ -7,3 +7,5 @@ pyyaml
|
|||||||
six
|
six
|
||||||
requests
|
requests
|
||||||
thesmuggler
|
thesmuggler
|
||||||
|
aprslib
|
||||||
|
py3-validate-email
|
||||||
|
@ -4,18 +4,21 @@
|
|||||||
#
|
#
|
||||||
# pip-compile
|
# pip-compile
|
||||||
#
|
#
|
||||||
|
aprslib==0.6.47 # via -r requirements.in
|
||||||
certifi==2020.12.5 # via requests
|
certifi==2020.12.5 # via requests
|
||||||
chardet==3.0.4 # via requests
|
chardet==4.0.0 # via requests
|
||||||
click-completion==0.5.2 # via -r requirements.in
|
click-completion==0.5.2 # via -r requirements.in
|
||||||
click==7.1.2 # via -r requirements.in, click-completion
|
click==7.1.2 # via -r requirements.in, click-completion
|
||||||
idna==2.10 # via requests
|
dnspython==2.0.0 # via py3-validate-email
|
||||||
|
filelock==3.0.12 # via py3-validate-email
|
||||||
|
idna==2.10 # via py3-validate-email, requests
|
||||||
imapclient==2.1.0 # via -r requirements.in
|
imapclient==2.1.0 # via -r requirements.in
|
||||||
jinja2==2.11.2 # via click-completion
|
jinja2==2.11.2 # via click-completion
|
||||||
markupsafe==1.1.1 # via jinja2
|
markupsafe==1.1.1 # via jinja2
|
||||||
pbr==5.5.1 # via -r requirements.in
|
pbr==5.5.1 # via -r requirements.in
|
||||||
pluggy==0.13.1 # via -r requirements.in
|
pluggy==0.13.1 # via -r requirements.in
|
||||||
|
py3-validate-email==0.2.12 # via -r requirements.in
|
||||||
pyyaml==5.3.1 # via -r requirements.in
|
pyyaml==5.3.1 # via -r requirements.in
|
||||||
requests==2.25.0 # via -r requirements.in
|
|
||||||
shellingham==1.3.2 # via click-completion
|
shellingham==1.3.2 # via click-completion
|
||||||
six==1.15.0 # via -r requirements.in, click-completion, imapclient
|
six==1.15.0 # via -r requirements.in, click-completion, imapclient
|
||||||
thesmuggler==1.0.1 # via -r requirements.in
|
thesmuggler==1.0.1 # via -r requirements.in
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
flake8
|
|
||||||
pytest
|
|
||||||
mock
|
|
@ -1,2 +0,0 @@
|
|||||||
flake8
|
|
||||||
pytest
|
|
@ -4,7 +4,7 @@ import unittest
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from aprsd import main
|
from aprsd import email
|
||||||
|
|
||||||
if sys.version_info >= (3, 2):
|
if sys.version_info >= (3, 2):
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@ -13,11 +13,12 @@ else:
|
|||||||
|
|
||||||
|
|
||||||
class testMain(unittest.TestCase):
|
class testMain(unittest.TestCase):
|
||||||
@mock.patch("aprsd.main._imap_connect")
|
@mock.patch("aprsd.email._imap_connect")
|
||||||
@mock.patch("aprsd.main._smtp_connect")
|
@mock.patch("aprsd.email._smtp_connect")
|
||||||
def test_validate_email(self, imap_mock, smtp_mock):
|
def test_validate_email(self, imap_mock, smtp_mock):
|
||||||
"""Test to make sure we fail."""
|
"""Test to make sure we fail."""
|
||||||
imap_mock.return_value = None
|
imap_mock.return_value = None
|
||||||
smtp_mock.return_value = {"smaiof": "fire"}
|
smtp_mock.return_value = {"smaiof": "fire"}
|
||||||
|
config = mock.MagicMock()
|
||||||
|
|
||||||
main.validate_email()
|
email.validate_email_config(config, True)
|
||||||
|
23
tox.ini
23
tox.ini
@ -2,7 +2,7 @@
|
|||||||
minversion = 2.9.0
|
minversion = 2.9.0
|
||||||
skipdist = True
|
skipdist = True
|
||||||
skip_missing_interpreters = true
|
skip_missing_interpreters = true
|
||||||
envlist = py{27,36,37,38},pep8,fmt-check
|
envlist = pep8,py{36,37,38},fmt-check
|
||||||
|
|
||||||
# Activate isolated build environment. tox will use a virtual environment
|
# Activate isolated build environment. tox will use a virtual environment
|
||||||
# to build a source distribution from the source tree. For build tools and
|
# to build a source distribution from the source tree. For build tools and
|
||||||
@ -14,7 +14,6 @@ setenv = VIRTUAL_ENV={envdir}
|
|||||||
usedevelop = True
|
usedevelop = True
|
||||||
install_command = pip install {opts} {packages}
|
install_command = pip install {opts} {packages}
|
||||||
deps = -r{toxinidir}/requirements.txt
|
deps = -r{toxinidir}/requirements.txt
|
||||||
-r{toxinidir}/test-requirements.txt
|
|
||||||
-r{toxinidir}/dev-requirements.txt
|
-r{toxinidir}/dev-requirements.txt
|
||||||
commands =
|
commands =
|
||||||
# Use -bb to enable BytesWarnings as error to catch str/bytes misuse.
|
# Use -bb to enable BytesWarnings as error to catch str/bytes misuse.
|
||||||
@ -23,29 +22,10 @@ commands =
|
|||||||
# --cov="{envsitepackagesdir}/aprsd" --cov-report=html --cov-report=term {posargs}
|
# --cov="{envsitepackagesdir}/aprsd" --cov-report=html --cov-report=term {posargs}
|
||||||
{envpython} -bb -m pytest {posargs}
|
{envpython} -bb -m pytest {posargs}
|
||||||
|
|
||||||
[testenv:py27]
|
|
||||||
setenv = VIRTUAL_ENV={envdir}
|
|
||||||
usedevelop = True
|
|
||||||
install_command = pip install {opts} {packages}
|
|
||||||
deps = -r{toxinidir}/requirements.txt
|
|
||||||
-r{toxinidir}/test-requirements-py2.txt
|
|
||||||
commands =
|
|
||||||
# Use -bb to enable BytesWarnings as error to catch str/bytes misuse.
|
|
||||||
# Use -Werror to treat warnings as errors.
|
|
||||||
# {envpython} -bb -Werror -m pytest \
|
|
||||||
# --cov="{envsitepackagesdir}/aprsd" --cov-report=html --cov-report=term {posargs}
|
|
||||||
{envpython} -bb -Werror -m pytest
|
|
||||||
|
|
||||||
[testenv:docs]
|
[testenv:docs]
|
||||||
deps = -r{toxinidir}/test-requirements.txt
|
deps = -r{toxinidir}/test-requirements.txt
|
||||||
commands = sphinx-build -b html docs/source docs/html
|
commands = sphinx-build -b html docs/source docs/html
|
||||||
|
|
||||||
[testenv:pep8-27]
|
|
||||||
deps = -r{toxinidir}/requirements.txt
|
|
||||||
-r{toxinidir}/test-requirements-py2.txt
|
|
||||||
commands =
|
|
||||||
flake8 {posargs} aprsd
|
|
||||||
|
|
||||||
[testenv:pep8]
|
[testenv:pep8]
|
||||||
commands =
|
commands =
|
||||||
flake8 {posargs} aprsd
|
flake8 {posargs} aprsd
|
||||||
@ -78,7 +58,6 @@ exclude = .venv,.git,.tox,dist,doc,.ropeproject
|
|||||||
# This section is not needed if not using GitHub Actions for CI.
|
# This section is not needed if not using GitHub Actions for CI.
|
||||||
[gh-actions]
|
[gh-actions]
|
||||||
python =
|
python =
|
||||||
2.7: py27, pep8-27
|
|
||||||
3.6: py36, pep8, fmt-check
|
3.6: py36, pep8, fmt-check
|
||||||
3.7: py38, pep8, fmt-check
|
3.7: py38, pep8, fmt-check
|
||||||
3.8: py38, pep8, fmt-check, type-check, docs
|
3.8: py38, pep8, fmt-check, type-check, docs
|
||||||
|
Loading…
x
Reference in New Issue
Block a user