Added webchat command
This patch adds the new aprsd webchat command which shows a new webpage that allows you to aprsd chat with multiple callsigns
@ -68,7 +68,7 @@ def main():
|
||||
# The commands themselves live in the cmds directory
|
||||
from .cmds import ( # noqa
|
||||
completion, dev, healthcheck, list_plugins, listen, send_message,
|
||||
server,
|
||||
server, webchat,
|
||||
)
|
||||
cli()
|
||||
|
||||
|
@ -5,6 +5,7 @@ import click
|
||||
|
||||
from aprsd import config as aprsd_config
|
||||
from aprsd.logging import log
|
||||
from aprsd.utils import trace
|
||||
|
||||
|
||||
F = t.TypeVar("F", bound=t.Callable[..., t.Any])
|
||||
@ -59,6 +60,8 @@ def process_standard_options(f: F) -> F:
|
||||
ctx.obj["loglevel"],
|
||||
ctx.obj["quiet"],
|
||||
)
|
||||
if ctx.obj["config"]["aprsd"].get("trace", False):
|
||||
trace.setup_tracing(["method", "api"])
|
||||
|
||||
del kwargs["loglevel"]
|
||||
del kwargs["config_file"]
|
||||
|
@ -52,6 +52,7 @@ class Client:
|
||||
|
||||
def reset(self):
|
||||
"""Call this to force a rebuild/reconnect."""
|
||||
if self._client:
|
||||
del self._client
|
||||
|
||||
@abc.abstractmethod
|
||||
@ -130,6 +131,7 @@ class APRSISClient(Client):
|
||||
backoff = backoff * 2
|
||||
continue
|
||||
LOG.debug(f"Logging in to APRS-IS with user '{user}'")
|
||||
self._client = aprs_client
|
||||
return aprs_client
|
||||
|
||||
|
||||
|
@ -1,5 +1,7 @@
|
||||
import logging
|
||||
import select
|
||||
import socket
|
||||
import threading
|
||||
|
||||
import aprslib
|
||||
from aprslib import is_py3
|
||||
@ -7,6 +9,7 @@ from aprslib.exceptions import (
|
||||
ConnectionDrop, ConnectionError, GenericError, LoginError, ParseError,
|
||||
UnknownFormat,
|
||||
)
|
||||
import wrapt
|
||||
|
||||
import aprsd
|
||||
from aprsd import stats
|
||||
@ -23,11 +26,30 @@ class Aprsdis(aprslib.IS):
|
||||
|
||||
# timeout in seconds
|
||||
select_timeout = 1
|
||||
lock = threading.Lock()
|
||||
|
||||
def stop(self):
|
||||
self.thread_stop = True
|
||||
LOG.info("Shutdown Aprsdis client.")
|
||||
|
||||
def is_socket_closed(self, sock: socket.socket) -> bool:
|
||||
try:
|
||||
# this will try to read bytes without blocking and also without removing them from buffer (peek only)
|
||||
data = sock.recv(16, socket.MSG_DONTWAIT | socket.MSG_PEEK)
|
||||
if len(data) == 0:
|
||||
return True
|
||||
except BlockingIOError:
|
||||
return False # socket is open and reading from it would block
|
||||
except ConnectionResetError:
|
||||
return True # socket was closed for some other reason
|
||||
except Exception:
|
||||
self.logger.exception(
|
||||
"unexpected exception when checking if a socket is closed",
|
||||
)
|
||||
return False
|
||||
return False
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def send(self, msg):
|
||||
"""Send an APRS Message object."""
|
||||
line = str(msg)
|
||||
|
@ -139,19 +139,24 @@ def listen(
|
||||
|
||||
# Creates the client object
|
||||
LOG.info("Creating client connection")
|
||||
client.factory.create().client
|
||||
aprs_client = client.factory.create().client
|
||||
aprs_client = client.factory.create()
|
||||
console.log(aprs_client)
|
||||
|
||||
LOG.debug(f"Filter by '{filter}'")
|
||||
aprs_client.set_filter(filter)
|
||||
aprs_client.client.set_filter(filter)
|
||||
|
||||
packets.PacketList(config=config)
|
||||
|
||||
keepalive = threads.KeepAliveThread(config=config)
|
||||
keepalive.start()
|
||||
|
||||
while True:
|
||||
try:
|
||||
# This will register a packet consumer with aprslib
|
||||
# When new packets come in the consumer will process
|
||||
# the packet
|
||||
with console.status("Listening for packets"):
|
||||
aprs_client.consumer(rx_packet, raw=False)
|
||||
# with console.status("Listening for packets"):
|
||||
aprs_client.client.consumer(rx_packet, raw=False)
|
||||
except aprslib.exceptions.ConnectionDrop:
|
||||
LOG.error("Connection dropped, reconnecting")
|
||||
time.sleep(5)
|
||||
|
@ -11,7 +11,6 @@ from aprsd import (
|
||||
)
|
||||
from aprsd import aprsd as aprsd_main
|
||||
from aprsd.aprsd import cli
|
||||
from aprsd.utils import trace
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
@ -59,8 +58,6 @@ def server(ctx, flush):
|
||||
else:
|
||||
LOG.info(f"{x} = {flat_config[x]}")
|
||||
|
||||
if config["aprsd"].get("trace", False):
|
||||
trace.setup_tracing(["method", "api"])
|
||||
stats.APRSDStats(config)
|
||||
|
||||
# Initialize the client factory and create
|
||||
@ -98,7 +95,7 @@ def server(ctx, flush):
|
||||
plugin_manager = plugin.PluginManager(config)
|
||||
plugin_manager.setup_plugins()
|
||||
|
||||
rx_thread = threads.APRSDRXThread(
|
||||
rx_thread = threads.APRSDPluginRXThread(
|
||||
msg_queues=threads.msg_queues,
|
||||
config=config,
|
||||
)
|
||||
|
592
aprsd/cmds/webchat.py
Normal file
@ -0,0 +1,592 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import queue
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import aprslib
|
||||
import click
|
||||
import flask
|
||||
from flask import request
|
||||
from flask.logging import default_handler
|
||||
import flask_classful
|
||||
from flask_httpauth import HTTPBasicAuth
|
||||
from flask_socketio import Namespace, SocketIO
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
import wrapt
|
||||
|
||||
import aprsd
|
||||
from aprsd import aprsd as aprsd_main
|
||||
from aprsd import cli_helper, client
|
||||
from aprsd import config as aprsd_config
|
||||
from aprsd import messaging, packets, stats, threads, utils
|
||||
from aprsd.aprsd import cli
|
||||
from aprsd.logging import rich as aprsd_logging
|
||||
from aprsd.threads import aprsd as aprsd_thread
|
||||
from aprsd.threads import rx
|
||||
from aprsd.utils import objectstore, trace
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
auth = HTTPBasicAuth()
|
||||
users = None
|
||||
rx_msg_queue = queue.Queue(maxsize=20)
|
||||
tx_msg_queue = queue.Queue(maxsize=20)
|
||||
control_queue = queue.Queue(maxsize=20)
|
||||
msg_queues = {
|
||||
"rx": rx_msg_queue,
|
||||
"control": control_queue,
|
||||
"tx": tx_msg_queue,
|
||||
}
|
||||
|
||||
|
||||
class SentMessages(objectstore.ObjectStoreMixin):
|
||||
_instance = None
|
||||
lock = threading.Lock()
|
||||
|
||||
data = {}
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
"""This magic turns this into a singleton."""
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def add(self, msg):
|
||||
self.data[msg.id] = self.create(msg.id)
|
||||
self.data[msg.id]["from"] = msg.fromcall
|
||||
self.data[msg.id]["to"] = msg.tocall
|
||||
self.data[msg.id]["message"] = msg.message.rstrip("\n")
|
||||
self.data[msg.id]["raw"] = str(msg).rstrip("\n")
|
||||
|
||||
def create(self, id):
|
||||
return {
|
||||
"id": id,
|
||||
"ts": time.time(),
|
||||
"ack": False,
|
||||
"from": None,
|
||||
"to": None,
|
||||
"raw": None,
|
||||
"message": None,
|
||||
"status": None,
|
||||
"last_update": None,
|
||||
"reply": None,
|
||||
}
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def __len__(self):
|
||||
return len(self.data.keys())
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def get(self, id):
|
||||
if id in self.data:
|
||||
return self.data[id]
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def get_all(self):
|
||||
return self.data
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def set_status(self, id, status):
|
||||
self.data[id]["last_update"] = str(datetime.datetime.now())
|
||||
self.data[id]["status"] = status
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def ack(self, id):
|
||||
"""The message got an ack!"""
|
||||
self.data[id]["last_update"] = str(datetime.datetime.now())
|
||||
self.data[id]["ack"] = True
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def reply(self, id, packet):
|
||||
"""We got a packet back from the sent message."""
|
||||
self.data[id]["reply"] = packet
|
||||
|
||||
|
||||
# HTTPBasicAuth doesn't work on a class method.
|
||||
# This has to be out here. Rely on the APRSDFlask
|
||||
# class to initialize the users from the config
|
||||
@auth.verify_password
|
||||
def verify_password(username, password):
|
||||
global users
|
||||
|
||||
if username in users and check_password_hash(users.get(username), password):
|
||||
return username
|
||||
|
||||
|
||||
class WebChatRXThread(rx.APRSDRXThread):
|
||||
"""Class that connects to aprsis and waits for messages."""
|
||||
|
||||
def connected(self, connected=True):
|
||||
self.connected = connected
|
||||
|
||||
def loop(self):
|
||||
|
||||
# setup the consumer of messages and block until a messages
|
||||
msg = None
|
||||
try:
|
||||
msg = self.msg_queues["tx"].get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
try:
|
||||
if msg:
|
||||
LOG.debug("GOT msg from TX queue!!")
|
||||
msg.send()
|
||||
except (
|
||||
aprslib.exceptions.ConnectionDrop,
|
||||
aprslib.exceptions.ConnectionError,
|
||||
):
|
||||
LOG.error("Connection dropped, reconnecting")
|
||||
# Put it back on the queue to send.
|
||||
self.msg_queues["tx"].put(msg)
|
||||
# Force the deletion of the client object connected to aprs
|
||||
# This will cause a reconnect, next time client.get_client()
|
||||
# is called
|
||||
self._client.reset()
|
||||
time.sleep(2)
|
||||
|
||||
try:
|
||||
# This will register a packet consumer with aprslib
|
||||
# When new packets come in the consumer will process
|
||||
# the packet
|
||||
|
||||
# Do a partial here because the consumer signature doesn't allow
|
||||
# For kwargs to be passed in to the consumer func we declare
|
||||
# and the aprslib developer didn't want to allow a PR to add
|
||||
# kwargs. :(
|
||||
# https://github.com/rossengeorgiev/aprs-python/pull/56
|
||||
self._client.client.consumer(
|
||||
self.process_packet, raw=False, blocking=False,
|
||||
)
|
||||
|
||||
except (
|
||||
aprslib.exceptions.ConnectionDrop,
|
||||
aprslib.exceptions.ConnectionError,
|
||||
):
|
||||
LOG.error("Connection dropped, reconnecting")
|
||||
time.sleep(5)
|
||||
# Force the deletion of the client object connected to aprs
|
||||
# This will cause a reconnect, next time client.get_client()
|
||||
# is called
|
||||
self._client.reset()
|
||||
# Continue to loop
|
||||
time.sleep(1)
|
||||
return True
|
||||
|
||||
def process_packet(self, *args, **kwargs):
|
||||
packet = self._client.decode_packet(*args, **kwargs)
|
||||
LOG.debug(f"GOT Packet {packet}")
|
||||
self.msg_queues["rx"].put(packet)
|
||||
|
||||
|
||||
class WebChatTXThread(aprsd_thread.APRSDThread):
|
||||
"""Class that """
|
||||
def __init__(self, msg_queues, config, socketio):
|
||||
super().__init__("_TXThread_")
|
||||
self.msg_queues = msg_queues
|
||||
self.config = config
|
||||
self.socketio = socketio
|
||||
self.connected = False
|
||||
|
||||
def loop(self):
|
||||
try:
|
||||
msg = self.msg_queues["control"].get_nowait()
|
||||
self.connected = msg["connected"]
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
packet = self.msg_queues["rx"].get_nowait()
|
||||
if packet:
|
||||
# we got a packet and we need to send it to the
|
||||
# web socket
|
||||
self.process_packet(packet)
|
||||
except queue.Empty:
|
||||
pass
|
||||
except Exception as ex:
|
||||
LOG.exception(ex)
|
||||
time.sleep(1)
|
||||
|
||||
return True
|
||||
|
||||
def process_ack_packet(self, packet):
|
||||
ack_num = packet.get("msgNo")
|
||||
LOG.info(f"We got ack for our sent message {ack_num}")
|
||||
messaging.log_packet(packet)
|
||||
SentMessages().ack(int(ack_num))
|
||||
self.socketio.emit(
|
||||
"ack", SentMessages().get(int(ack_num)),
|
||||
namespace="/sendmsg",
|
||||
)
|
||||
stats.APRSDStats().ack_rx_inc()
|
||||
self.got_ack = True
|
||||
|
||||
def process_packet(self, packet):
|
||||
tocall = packet.get("addresse", None)
|
||||
fromcall = packet["from"]
|
||||
msg = packet.get("message_text", None)
|
||||
msg_id = packet.get("msgNo", "0")
|
||||
msg_response = packet.get("response", None)
|
||||
|
||||
if tocall == self.config["aprs"]["login"] and msg_response == "ack":
|
||||
self.process_ack_packet(packet)
|
||||
elif tocall == self.config["aprs"]["login"]:
|
||||
messaging.log_message(
|
||||
"Received Message",
|
||||
packet["raw"],
|
||||
msg,
|
||||
fromcall=fromcall,
|
||||
msg_num=msg_id,
|
||||
)
|
||||
# let any threads do their thing, then ack
|
||||
# send an ack last
|
||||
ack = messaging.AckMessage(
|
||||
self.config["aprs"]["login"],
|
||||
fromcall,
|
||||
msg_id=msg_id,
|
||||
)
|
||||
self.msg_queues["tx"].put(ack)
|
||||
|
||||
packets.PacketList().add(packet)
|
||||
stats.APRSDStats().msgs_rx_inc()
|
||||
message = packet.get("message_text", None)
|
||||
msg = {
|
||||
"id": 0,
|
||||
"ts": time.time(),
|
||||
"ack": False,
|
||||
"from": fromcall,
|
||||
"to": packet["to"],
|
||||
"raw": packet["raw"],
|
||||
"message": message,
|
||||
"status": None,
|
||||
"last_update": None,
|
||||
"reply": None,
|
||||
}
|
||||
self.socketio.emit(
|
||||
"new", msg,
|
||||
namespace="/sendmsg",
|
||||
)
|
||||
|
||||
|
||||
class WebChatFlask(flask_classful.FlaskView):
|
||||
config = None
|
||||
|
||||
def set_config(self, config):
|
||||
global users
|
||||
self.config = config
|
||||
self.users = {}
|
||||
for user in self.config["aprsd"]["web"]["users"]:
|
||||
self.users[user] = generate_password_hash(
|
||||
self.config["aprsd"]["web"]["users"][user],
|
||||
)
|
||||
|
||||
users = self.users
|
||||
|
||||
@auth.login_required
|
||||
def index(self):
|
||||
stats = self._stats()
|
||||
|
||||
if self.config["aprs"].get("enabled", True):
|
||||
transport = "aprs-is"
|
||||
aprs_connection = (
|
||||
"APRS-IS Server: <a href='http://status.aprs2.net' >"
|
||||
"{}</a>".format(stats["stats"]["aprs-is"]["server"])
|
||||
)
|
||||
else:
|
||||
# We might be connected to a KISS socket?
|
||||
if client.KISSClient.kiss_enabled(self.config):
|
||||
transport = client.KISSClient.transport(self.config)
|
||||
if transport == client.TRANSPORT_TCPKISS:
|
||||
aprs_connection = (
|
||||
"TCPKISS://{}:{}".format(
|
||||
self.config["kiss"]["tcp"]["host"],
|
||||
self.config["kiss"]["tcp"]["port"],
|
||||
)
|
||||
)
|
||||
elif transport == client.TRANSPORT_SERIALKISS:
|
||||
aprs_connection = (
|
||||
"SerialKISS://{}@{} baud".format(
|
||||
self.config["kiss"]["serial"]["device"],
|
||||
self.config["kiss"]["serial"]["baudrate"],
|
||||
)
|
||||
)
|
||||
|
||||
stats["transport"] = transport
|
||||
stats["aprs_connection"] = aprs_connection
|
||||
LOG.debug(f"initial stats = {stats}")
|
||||
|
||||
return flask.render_template(
|
||||
"index.html",
|
||||
initial_stats=stats,
|
||||
aprs_connection=aprs_connection,
|
||||
callsign=self.config["aprs"]["login"],
|
||||
version=aprsd.__version__,
|
||||
)
|
||||
|
||||
@auth.login_required
|
||||
def send_message_status(self):
|
||||
LOG.debug(request)
|
||||
msgs = SentMessages()
|
||||
info = msgs.get_all()
|
||||
return json.dumps(info)
|
||||
|
||||
@trace.trace
|
||||
def _stats(self):
|
||||
stats_obj = stats.APRSDStats()
|
||||
now = datetime.datetime.now()
|
||||
|
||||
time_format = "%m-%d-%Y %H:%M:%S"
|
||||
stats_dict = stats_obj.stats()
|
||||
# Webchat doesnt need these
|
||||
del stats_dict["aprsd"]["watch_list"]
|
||||
del stats_dict["aprsd"]["seen_list"]
|
||||
# del stats_dict["email"]
|
||||
# del stats_dict["plugins"]
|
||||
# del stats_dict["messages"]
|
||||
|
||||
result = {
|
||||
"time": now.strftime(time_format),
|
||||
"stats": stats_dict,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def stats(self):
|
||||
return json.dumps(self._stats())
|
||||
|
||||
|
||||
class SendMessageNamespace(Namespace):
|
||||
"""Class to handle the socketio interactions."""
|
||||
_config = None
|
||||
got_ack = False
|
||||
reply_sent = False
|
||||
msg = None
|
||||
request = None
|
||||
|
||||
def __init__(self, namespace=None, config=None, msg_queues=None):
|
||||
self._config = config
|
||||
self._msg_queues = msg_queues
|
||||
super().__init__(namespace)
|
||||
|
||||
def on_connect(self):
|
||||
global socketio
|
||||
LOG.debug("Web socket connected")
|
||||
socketio.emit(
|
||||
"connected", {"data": "/sendmsg Connected"},
|
||||
namespace="/sendmsg",
|
||||
)
|
||||
msg = {"connected": True}
|
||||
self._msg_queues["control"].put(msg)
|
||||
|
||||
def on_disconnect(self):
|
||||
LOG.debug("WS Disconnected")
|
||||
msg = {"connected": False}
|
||||
self._msg_queues["control"].put(msg)
|
||||
|
||||
def on_send(self, data):
|
||||
global socketio
|
||||
LOG.debug(f"WS: on_send {data}")
|
||||
self.request = data
|
||||
data["from"] = self._config["aprs"]["login"]
|
||||
msg = messaging.TextMessage(
|
||||
data["from"],
|
||||
data["to"],
|
||||
data["message"],
|
||||
)
|
||||
self.msg = msg
|
||||
msgs = SentMessages()
|
||||
msgs.add(msg)
|
||||
msgs.set_status(msg.id, "Sending")
|
||||
socketio.emit(
|
||||
"sent", SentMessages().get(self.msg.id),
|
||||
namespace="/sendmsg",
|
||||
)
|
||||
|
||||
self._msg_queues["tx"].put(msg)
|
||||
|
||||
def handle_message(self, data):
|
||||
LOG.debug(f"WS Data {data}")
|
||||
|
||||
def handle_json(self, data):
|
||||
LOG.debug(f"WS json {data}")
|
||||
|
||||
|
||||
def setup_logging(config, flask_app, loglevel, quiet):
|
||||
flask_log = logging.getLogger("werkzeug")
|
||||
flask_app.logger.removeHandler(default_handler)
|
||||
flask_log.removeHandler(default_handler)
|
||||
|
||||
log_level = aprsd_config.LOG_LEVELS[loglevel]
|
||||
flask_log.setLevel(log_level)
|
||||
date_format = config["aprsd"].get(
|
||||
"dateformat",
|
||||
aprsd_config.DEFAULT_DATE_FORMAT,
|
||||
)
|
||||
|
||||
if not config["aprsd"]["web"].get("logging_enabled", False):
|
||||
# disable web logging
|
||||
flask_log.disabled = True
|
||||
flask_app.logger.disabled = True
|
||||
# return
|
||||
|
||||
if config["aprsd"].get("rich_logging", False) and not quiet:
|
||||
log_format = "%(message)s"
|
||||
log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
|
||||
rh = aprsd_logging.APRSDRichHandler(
|
||||
show_thread=True, thread_width=15,
|
||||
rich_tracebacks=True, omit_repeated_times=False,
|
||||
)
|
||||
rh.setFormatter(log_formatter)
|
||||
flask_log.addHandler(rh)
|
||||
|
||||
log_file = config["aprsd"].get("logfile", None)
|
||||
|
||||
if log_file:
|
||||
log_format = config["aprsd"].get(
|
||||
"logformat",
|
||||
aprsd_config.DEFAULT_LOG_FORMAT,
|
||||
)
|
||||
log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
|
||||
fh = RotatingFileHandler(
|
||||
log_file, maxBytes=(10248576 * 5),
|
||||
backupCount=4,
|
||||
)
|
||||
fh.setFormatter(log_formatter)
|
||||
flask_log.addHandler(fh)
|
||||
|
||||
|
||||
@trace.trace
|
||||
def init_flask(config, loglevel, quiet):
|
||||
global socketio
|
||||
|
||||
flask_app = flask.Flask(
|
||||
"aprsd",
|
||||
static_url_path="/static",
|
||||
static_folder="web/chat/static",
|
||||
template_folder="web/chat/templates",
|
||||
)
|
||||
setup_logging(config, flask_app, loglevel, quiet)
|
||||
server = WebChatFlask()
|
||||
server.set_config(config)
|
||||
flask_app.route("/", methods=["GET"])(server.index)
|
||||
flask_app.route("/stats", methods=["GET"])(server.stats)
|
||||
# flask_app.route("/send-message", methods=["GET"])(server.send_message)
|
||||
flask_app.route("/send-message-status", methods=["GET"])(server.send_message_status)
|
||||
|
||||
socketio = SocketIO(
|
||||
flask_app, logger=False, engineio_logger=False,
|
||||
async_mode="threading",
|
||||
)
|
||||
# async_mode="gevent",
|
||||
# async_mode="eventlet",
|
||||
# import eventlet
|
||||
# eventlet.monkey_patch()
|
||||
|
||||
socketio.on_namespace(
|
||||
SendMessageNamespace(
|
||||
"/sendmsg", config=config,
|
||||
msg_queues=msg_queues,
|
||||
),
|
||||
)
|
||||
return socketio, flask_app
|
||||
|
||||
|
||||
# main() ###
|
||||
@cli.command()
|
||||
@cli_helper.add_options(cli_helper.common_options)
|
||||
@click.option(
|
||||
"-f",
|
||||
"--flush",
|
||||
"flush",
|
||||
is_flag=True,
|
||||
show_default=True,
|
||||
default=False,
|
||||
help="Flush out all old aged messages on disk.",
|
||||
)
|
||||
@click.option(
|
||||
"-p",
|
||||
"--port",
|
||||
"port",
|
||||
show_default=True,
|
||||
default=80,
|
||||
help="Port to listen to web requests",
|
||||
)
|
||||
@click.pass_context
|
||||
@cli_helper.process_standard_options
|
||||
def webchat(ctx, flush, port):
|
||||
"""Web based HAM Radio chat program!"""
|
||||
ctx.obj["config_file"]
|
||||
loglevel = ctx.obj["loglevel"]
|
||||
quiet = ctx.obj["quiet"]
|
||||
config = ctx.obj["config"]
|
||||
|
||||
signal.signal(signal.SIGINT, aprsd_main.signal_handler)
|
||||
signal.signal(signal.SIGTERM, aprsd_main.signal_handler)
|
||||
|
||||
if not quiet:
|
||||
click.echo("Load config")
|
||||
|
||||
level, msg = utils._check_version()
|
||||
if level:
|
||||
LOG.warning(msg)
|
||||
else:
|
||||
LOG.info(msg)
|
||||
LOG.info(f"APRSD Started version: {aprsd.__version__}")
|
||||
|
||||
flat_config = utils.flatten_dict(config)
|
||||
LOG.info("Using CONFIG values:")
|
||||
for x in flat_config:
|
||||
if "password" in x or "aprsd.web.users.admin" in x:
|
||||
LOG.info(f"{x} = XXXXXXXXXXXXXXXXXXX")
|
||||
else:
|
||||
LOG.info(f"{x} = {flat_config[x]}")
|
||||
|
||||
stats.APRSDStats(config)
|
||||
|
||||
# Initialize the client factory and create
|
||||
# The correct client object ready for use
|
||||
client.ClientFactory.setup(config)
|
||||
# Make sure we have 1 client transport enabled
|
||||
if not client.factory.is_client_enabled():
|
||||
LOG.error("No Clients are enabled in config.")
|
||||
sys.exit(-1)
|
||||
|
||||
if not client.factory.is_client_configured():
|
||||
LOG.error("APRS client is not properly configured in config file.")
|
||||
sys.exit(-1)
|
||||
|
||||
packets.PacketList(config=config)
|
||||
messaging.MsgTrack(config=config)
|
||||
packets.WatchList(config=config)
|
||||
packets.SeenList(config=config)
|
||||
|
||||
aprsd_main.flask_enabled = True
|
||||
(socketio, app) = init_flask(config, loglevel, quiet)
|
||||
rx_thread = WebChatRXThread(
|
||||
msg_queues=msg_queues,
|
||||
config=config,
|
||||
)
|
||||
LOG.warning("Start RX Thread")
|
||||
rx_thread.start()
|
||||
tx_thread = WebChatTXThread(
|
||||
msg_queues=msg_queues,
|
||||
config=config,
|
||||
socketio=socketio,
|
||||
)
|
||||
LOG.warning("Start TX Thread")
|
||||
tx_thread.start()
|
||||
|
||||
keepalive = threads.KeepAliveThread(config=config)
|
||||
LOG.warning("Start KeepAliveThread")
|
||||
keepalive.start()
|
||||
LOG.warning("Start socketio.run()")
|
||||
socketio.run(
|
||||
app,
|
||||
host=config["aprsd"]["web"]["host"],
|
||||
port=port,
|
||||
)
|
@ -601,8 +601,8 @@ def init_flask(config, loglevel, quiet):
|
||||
flask_app = flask.Flask(
|
||||
"aprsd",
|
||||
static_url_path="/static",
|
||||
static_folder="web/static",
|
||||
template_folder="web/templates",
|
||||
static_folder="web/admin/static",
|
||||
template_folder="web/admin/templates",
|
||||
)
|
||||
setup_logging(config, flask_app, loglevel, quiet)
|
||||
server = APRSDFlask()
|
||||
|
@ -6,7 +6,8 @@ import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
from aprsd import client, objectstore, packets, stats, threads
|
||||
from aprsd import client, packets, stats, threads
|
||||
from aprsd.utils import objectstore
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
@ -238,7 +239,10 @@ class RawMessage(Message):
|
||||
last_send_age = last_send_time = None
|
||||
|
||||
def __init__(self, message, allow_delay=True):
|
||||
super().__init__(fromcall=None, tocall=None, msg_id=None, allow_delay=allow_delay)
|
||||
super().__init__(
|
||||
fromcall=None, tocall=None, msg_id=None,
|
||||
allow_delay=allow_delay,
|
||||
)
|
||||
self._raw_message = message
|
||||
|
||||
def dict(self):
|
||||
@ -282,12 +286,8 @@ class TextMessage(Message):
|
||||
last_send_time = last_send_age = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fromcall,
|
||||
tocall,
|
||||
message,
|
||||
msg_id=None,
|
||||
allow_delay=True,
|
||||
self, fromcall, tocall, message,
|
||||
msg_id=None, allow_delay=True,
|
||||
):
|
||||
super().__init__(
|
||||
fromcall=fromcall, tocall=tocall,
|
||||
|
@ -3,7 +3,8 @@ import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from aprsd import objectstore, utils
|
||||
from aprsd import utils
|
||||
from aprsd.utils import objectstore
|
||||
|
||||
|
||||
LOG = logging.getLogger("APRSD")
|
||||
@ -77,9 +78,10 @@ class WatchList(objectstore.ObjectStoreMixin):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance.lock = threading.Lock()
|
||||
if "config" in kwargs:
|
||||
cls._instance.config = kwargs["config"]
|
||||
cls._instance.data = {}
|
||||
cls._instance._init_store()
|
||||
cls._instance.data = {}
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, config=None):
|
||||
@ -165,9 +167,10 @@ class SeenList(objectstore.ObjectStoreMixin):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance.lock = threading.Lock()
|
||||
if "config" in kwargs:
|
||||
cls._instance.config = kwargs["config"]
|
||||
cls._instance.data = {}
|
||||
cls._instance._init_store()
|
||||
cls._instance.data = {}
|
||||
return cls._instance
|
||||
|
||||
def update_seen(self, packet):
|
||||
|
@ -492,4 +492,5 @@ class PluginManager:
|
||||
self._pluggy_pm.register(obj)
|
||||
|
||||
def get_plugins(self):
|
||||
if self._pluggy_pm:
|
||||
return self._pluggy_pm.get_plugins()
|
||||
|
@ -2,6 +2,8 @@ import datetime
|
||||
import logging
|
||||
import threading
|
||||
|
||||
import wrapt
|
||||
|
||||
import aprsd
|
||||
from aprsd import packets, plugin, utils
|
||||
|
||||
@ -12,7 +14,7 @@ LOG = logging.getLogger("APRSD")
|
||||
class APRSDStats:
|
||||
|
||||
_instance = None
|
||||
lock = None
|
||||
lock = threading.Lock()
|
||||
config = None
|
||||
|
||||
start_time = None
|
||||
@ -39,7 +41,6 @@ class APRSDStats:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
# any initializetion here
|
||||
cls._instance.lock = threading.Lock()
|
||||
cls._instance.start_time = datetime.datetime.now()
|
||||
cls._instance._aprsis_keepalive = datetime.datetime.now()
|
||||
return cls._instance
|
||||
@ -48,128 +49,129 @@ class APRSDStats:
|
||||
if config:
|
||||
self.config = config
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def uptime(self):
|
||||
with self.lock:
|
||||
return datetime.datetime.now() - self.start_time
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def memory(self):
|
||||
with self.lock:
|
||||
return self._mem_current
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def set_memory(self, memory):
|
||||
with self.lock:
|
||||
self._mem_current = memory
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def memory_peak(self):
|
||||
with self.lock:
|
||||
return self._mem_peak
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def set_memory_peak(self, memory):
|
||||
with self.lock:
|
||||
self._mem_peak = memory
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def aprsis_server(self):
|
||||
with self.lock:
|
||||
return self._aprsis_server
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def set_aprsis_server(self, server):
|
||||
with self.lock:
|
||||
self._aprsis_server = server
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def aprsis_keepalive(self):
|
||||
with self.lock:
|
||||
return self._aprsis_keepalive
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def set_aprsis_keepalive(self):
|
||||
with self.lock:
|
||||
self._aprsis_keepalive = datetime.datetime.now()
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def msgs_tx(self):
|
||||
with self.lock:
|
||||
return self._msgs_tx
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def msgs_tx_inc(self):
|
||||
with self.lock:
|
||||
self._msgs_tx += 1
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def msgs_rx(self):
|
||||
with self.lock:
|
||||
return self._msgs_rx
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def msgs_rx_inc(self):
|
||||
with self.lock:
|
||||
self._msgs_rx += 1
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def msgs_mice_rx(self):
|
||||
with self.lock:
|
||||
return self._msgs_mice_rx
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def msgs_mice_inc(self):
|
||||
with self.lock:
|
||||
self._msgs_mice_rx += 1
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def ack_tx(self):
|
||||
with self.lock:
|
||||
return self._ack_tx
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def ack_tx_inc(self):
|
||||
with self.lock:
|
||||
self._ack_tx += 1
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def ack_rx(self):
|
||||
with self.lock:
|
||||
return self._ack_rx
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def ack_rx_inc(self):
|
||||
with self.lock:
|
||||
self._ack_rx += 1
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def msgs_tracked(self):
|
||||
with self.lock:
|
||||
return self._msgs_tracked
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def msgs_tracked_inc(self):
|
||||
with self.lock:
|
||||
self._msgs_tracked += 1
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def email_tx(self):
|
||||
with self.lock:
|
||||
return self._email_tx
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def email_tx_inc(self):
|
||||
with self.lock:
|
||||
self._email_tx += 1
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def email_rx(self):
|
||||
with self.lock:
|
||||
return self._email_rx
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def email_rx_inc(self):
|
||||
with self.lock:
|
||||
self._email_rx += 1
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
@property
|
||||
def email_thread_time(self):
|
||||
with self.lock:
|
||||
return self._email_thread_last_time
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def email_thread_update(self):
|
||||
with self.lock:
|
||||
self._email_thread_last_time = datetime.datetime.now()
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def stats(self):
|
||||
now = datetime.datetime.now()
|
||||
if self._email_thread_last_time:
|
||||
@ -185,7 +187,7 @@ class APRSDStats:
|
||||
pm = plugin.PluginManager()
|
||||
plugins = pm.get_plugins()
|
||||
plugin_stats = {}
|
||||
|
||||
if plugins:
|
||||
def full_name_with_qualname(obj):
|
||||
return "{}.{}".format(
|
||||
obj.__class__.__module__,
|
||||
@ -207,30 +209,30 @@ class APRSDStats:
|
||||
"aprsd": {
|
||||
"version": aprsd.__version__,
|
||||
"uptime": utils.strfdelta(self.uptime),
|
||||
"memory_current": self.memory,
|
||||
"memory_current": int(self.memory),
|
||||
"memory_current_str": utils.human_size(self.memory),
|
||||
"memory_peak": self.memory_peak,
|
||||
"memory_peak": int(self.memory_peak),
|
||||
"memory_peak_str": utils.human_size(self.memory_peak),
|
||||
"watch_list": wl.get_all(),
|
||||
"seen_list": sl.get_all(),
|
||||
},
|
||||
"aprs-is": {
|
||||
"server": self.aprsis_server,
|
||||
"server": str(self.aprsis_server),
|
||||
"callsign": self.config["aprs"]["login"],
|
||||
"last_update": last_aprsis_keepalive,
|
||||
},
|
||||
"messages": {
|
||||
"tracked": self.msgs_tracked,
|
||||
"sent": self.msgs_tx,
|
||||
"recieved": self.msgs_rx,
|
||||
"ack_sent": self.ack_tx,
|
||||
"ack_recieved": self.ack_rx,
|
||||
"mic-e recieved": self.msgs_mice_rx,
|
||||
"tracked": int(self.msgs_tracked),
|
||||
"sent": int(self.msgs_tx),
|
||||
"recieved": int(self.msgs_rx),
|
||||
"ack_sent": int(self.ack_tx),
|
||||
"ack_recieved": int(self.ack_rx),
|
||||
"mic-e recieved": int(self.msgs_mice_rx),
|
||||
},
|
||||
"email": {
|
||||
"enabled": self.config["aprsd"]["email"]["enabled"],
|
||||
"sent": self._email_tx,
|
||||
"recieved": self._email_rx,
|
||||
"sent": int(self._email_tx),
|
||||
"recieved": int(self._email_rx),
|
||||
"thread_last_update": last_update,
|
||||
},
|
||||
"plugins": plugin_stats,
|
||||
|
@ -73,7 +73,7 @@ class KeepAliveThread(APRSDThread):
|
||||
# We haven't gotten a keepalive from aprs-is in a while
|
||||
# reset the connection.a
|
||||
if not client.KISSClient.is_enabled(self.config):
|
||||
LOG.warning("Resetting connection to APRS-IS.")
|
||||
LOG.warning(f"Resetting connection to APRS-IS {delta}")
|
||||
client.factory.create().reset()
|
||||
|
||||
# Check version every hour
|
||||
|
@ -1,3 +1,4 @@
|
||||
import abc
|
||||
import logging
|
||||
import time
|
||||
|
||||
@ -38,7 +39,10 @@ class APRSDRXThread(APRSDThread):
|
||||
self.process_packet, raw=False, blocking=False,
|
||||
)
|
||||
|
||||
except aprslib.exceptions.ConnectionDrop:
|
||||
except (
|
||||
aprslib.exceptions.ConnectionDrop,
|
||||
aprslib.exceptions.ConnectionError,
|
||||
):
|
||||
LOG.error("Connection dropped, reconnecting")
|
||||
time.sleep(5)
|
||||
# Force the deletion of the client object connected to aprs
|
||||
@ -48,6 +52,12 @@ class APRSDRXThread(APRSDThread):
|
||||
# Continue to loop
|
||||
return True
|
||||
|
||||
@abc.abstractmethod
|
||||
def process_packet(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class APRSDPluginRXThread(APRSDRXThread):
|
||||
def process_packet(self, *args, **kwargs):
|
||||
packet = self._client.decode_packet(*args, **kwargs)
|
||||
thread = APRSDProcessPacketThread(packet=packet, config=self.config)
|
||||
|
0
aprsd/web/__init__.py
Normal file
0
aprsd/web/admin/__init__.py
Normal file
BIN
aprsd/web/admin/static/images/Untitled.png
Normal file
After Width: | Height: | Size: 37 KiB |
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
94
aprsd/web/chat/static/css/index.css
Normal file
@ -0,0 +1,94 @@
|
||||
body {
|
||||
background: #eeeeee;
|
||||
margin: 2em;
|
||||
text-align: center;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 2em;
|
||||
text-align: center;
|
||||
height: 10vh;
|
||||
}
|
||||
|
||||
.ui.segment {
|
||||
background: #eeeeee;
|
||||
}
|
||||
|
||||
ul.list {
|
||||
list-style-type: disc;
|
||||
}
|
||||
ul.list li {
|
||||
list-style-position: outside;
|
||||
}
|
||||
|
||||
#left {
|
||||
margin-right: 2px;
|
||||
height: 300px;
|
||||
}
|
||||
#right {
|
||||
height: 300px;
|
||||
}
|
||||
#center {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
#title {
|
||||
font-size: 4em;
|
||||
}
|
||||
#version{
|
||||
font-size: .5em;
|
||||
}
|
||||
#uptime, #aprsis {
|
||||
font-size: 1em;
|
||||
}
|
||||
#callsign {
|
||||
font-size: 1.4em;
|
||||
color: #00F;
|
||||
padding-top: 8px;
|
||||
margin:10px;
|
||||
}
|
||||
|
||||
#title_rx {
|
||||
background-color: darkseagreen;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#title_tx {
|
||||
background-color: lightcoral;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.aprsd_1 {
|
||||
background-image: url(/static/images/aprs-symbols-16-0.png);
|
||||
background-repeat: no-repeat;
|
||||
background-position: -160px -48px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
#msgsTabsDiv .ui.tab {
|
||||
margin:0px;
|
||||
padding:0px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#msgsTabsDiv .header, .tiny.text, .content, .break,
|
||||
.thumbs.down.outline.icon,
|
||||
.phone.volume.icon
|
||||
{
|
||||
display: inline-block;
|
||||
float: left;
|
||||
position: relative;
|
||||
}
|
||||
#msgsTabsDiv .tiny.text {
|
||||
width:100px;
|
||||
}
|
||||
#msgsTabsDiv .tiny.header {
|
||||
width:100px;
|
||||
text-align: left;
|
||||
}
|
||||
#msgsTabsDiv .break {
|
||||
margin: 2px;
|
||||
text-align: left;
|
||||
}
|
1
aprsd/web/chat/static/css/style.css.map
Normal file
41
aprsd/web/chat/static/css/tabs.css
Normal file
@ -0,0 +1,41 @@
|
||||
* {box-sizing: border-box}
|
||||
|
||||
/* Style the tab */
|
||||
.tab {
|
||||
border: 1px solid #ccc;
|
||||
background-color: #f1f1f1;
|
||||
height: 450px;
|
||||
}
|
||||
|
||||
/* Style the buttons inside the tab */
|
||||
.tab div {
|
||||
display: block;
|
||||
background-color: inherit;
|
||||
color: black;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: 0.3s;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
/* Change background color of buttons on hover */
|
||||
.tab div:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
/* Create an active/current "tab button" class */
|
||||
.tab div.active {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
/* Style the tab content */
|
||||
.tabcontent {
|
||||
border: 1px solid #ccc;
|
||||
height: 450px;
|
||||
overflow-y: scroll;
|
||||
background-color: white;
|
||||
}
|
BIN
aprsd/web/chat/static/images/Untitled.png
Normal file
After Width: | Height: | Size: 37 KiB |
BIN
aprsd/web/chat/static/images/aprs-symbols-16-0.png
Normal file
After Width: | Height: | Size: 52 KiB |
BIN
aprsd/web/chat/static/images/aprs-symbols-16-1.png
Normal file
After Width: | Height: | Size: 48 KiB |
BIN
aprsd/web/chat/static/images/aprs-symbols-64-0.png
Normal file
After Width: | Height: | Size: 52 KiB |
BIN
aprsd/web/chat/static/images/aprs-symbols-64-1.png
Normal file
After Width: | Height: | Size: 48 KiB |
BIN
aprsd/web/chat/static/images/aprs-symbols-64-2.png
Normal file
After Width: | Height: | Size: 40 KiB |
44
aprsd/web/chat/static/js/main.js
Normal file
@ -0,0 +1,44 @@
|
||||
function aprs_img(item, x_offset, y_offset) {
|
||||
var x = x_offset * -16;
|
||||
if (y_offset > 5) {
|
||||
y_offset = 5;
|
||||
}
|
||||
var y = y_offset * -16;
|
||||
var loc = x + 'px '+ y + 'px'
|
||||
item.css('background-position', loc);
|
||||
}
|
||||
|
||||
function show_aprs_icon(item, symbol) {
|
||||
var offset = ord(symbol) - 33;
|
||||
var col = Math.floor(offset / 16);
|
||||
var row = offset % 16;
|
||||
//console.log("'" + symbol+"' off: "+offset+" row: "+ row + " col: " + col)
|
||||
aprs_img(item, row, col);
|
||||
}
|
||||
|
||||
function ord(str){return str.charCodeAt(0);}
|
||||
|
||||
function update_stats( data ) {
|
||||
$("#version").text( data["stats"]["aprsd"]["version"] );
|
||||
$("#aprs_connection").html( data["aprs_connection"] );
|
||||
$("#uptime").text( "uptime: " + data["stats"]["aprsd"]["uptime"] );
|
||||
short_time = data["time"].split(/\s(.+)/)[1];
|
||||
}
|
||||
|
||||
|
||||
function start_update() {
|
||||
|
||||
(function statsworker() {
|
||||
$.ajax({
|
||||
url: "/stats",
|
||||
type: 'GET',
|
||||
dataType: 'json',
|
||||
success: function(data) {
|
||||
update_stats(data);
|
||||
},
|
||||
complete: function() {
|
||||
setTimeout(statsworker, 10000);
|
||||
}
|
||||
});
|
||||
})();
|
||||
}
|
215
aprsd/web/chat/static/js/send-message.js
Normal file
@ -0,0 +1,215 @@
|
||||
var cleared = false;
|
||||
var callsign_list = {};
|
||||
var message_list = {};
|
||||
|
||||
function size_dict(d){c=0; for (i in d) ++c; return c}
|
||||
|
||||
function init_chat() {
|
||||
const socket = io("/sendmsg");
|
||||
socket.on('connect', function () {
|
||||
console.log("Connected to socketio");
|
||||
});
|
||||
socket.on('connected', function(msg) {
|
||||
console.log("Connected!");
|
||||
console.log(msg);
|
||||
});
|
||||
|
||||
socket.on("sent", function(msg) {
|
||||
if (cleared == false) {
|
||||
var msgsdiv = $("#msgsTabsDiv");
|
||||
msgsdiv.html('')
|
||||
cleared = true
|
||||
}
|
||||
sent_msg(msg);
|
||||
});
|
||||
|
||||
socket.on("ack", function(msg) {
|
||||
update_msg(msg);
|
||||
});
|
||||
|
||||
socket.on("new", function(msg) {
|
||||
if (cleared == false) {
|
||||
var msgsdiv = $("#msgsTabsDiv");
|
||||
msgsdiv.html('')
|
||||
cleared = true
|
||||
}
|
||||
from_msg(msg);
|
||||
});
|
||||
|
||||
$("#sendform").submit(function(event) {
|
||||
event.preventDefault();
|
||||
msg = {'to': $('#to_call').val(),
|
||||
'message': $('#message').val(),
|
||||
}
|
||||
socket.emit("send", msg);
|
||||
$('#message').val('');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function add_callsign(callsign) {
|
||||
/* Ensure a callsign exists in the left hand nav */
|
||||
|
||||
if (callsign in callsign_list) {
|
||||
return false
|
||||
}
|
||||
|
||||
var callsignTabs = $("#callsignTabs");
|
||||
tab_name = tab_string(callsign);
|
||||
tab_content = tab_content_name(callsign);
|
||||
divname = content_divname(callsign);
|
||||
|
||||
item_html = '<div class="tablinks" id="'+tab_name+'" onclick="openCallsign(event, \''+callsign+'\');">'+callsign+'</div>';
|
||||
callsignTabs.append(item_html);
|
||||
callsign_list[callsign] = true;
|
||||
return true
|
||||
}
|
||||
|
||||
function append_message(callsign, msg, msg_html) {
|
||||
new_callsign = false
|
||||
if (!message_list.hasOwnProperty(callsign)) {
|
||||
message_list[callsign] = new Array();
|
||||
}
|
||||
message_list[callsign].push(msg);
|
||||
|
||||
// Find the right div to place the html
|
||||
new_callsign = add_callsign(callsign);
|
||||
append_message_html(callsign, msg_html, new_callsign);
|
||||
if (new_callsign) {
|
||||
//click on the new tab
|
||||
click_div = '#'+tab_string(callsign);
|
||||
$(click_div).click();
|
||||
}
|
||||
}
|
||||
|
||||
function tab_string(callsign) {
|
||||
return "msgs"+callsign;
|
||||
}
|
||||
|
||||
function tab_content_name(callsign) {
|
||||
return tab_string(callsign)+"Content";
|
||||
}
|
||||
|
||||
function content_divname(callsign) {
|
||||
return "#"+tab_content_name(callsign);
|
||||
}
|
||||
|
||||
function append_message_html(callsign, msg_html, new_callsign) {
|
||||
var msgsTabs = $('#msgsTabsDiv');
|
||||
divname_str = tab_content_name(callsign);
|
||||
divname = content_divname(callsign);
|
||||
if (new_callsign) {
|
||||
// we have to add a new DIV
|
||||
msg_div_html = '<div class="tabcontent" id="'+divname_str+'" style="height:450px;">'+msg_html+'</div>';
|
||||
msgsTabs.append(msg_div_html);
|
||||
} else {
|
||||
var msgDiv = $(divname);
|
||||
msgDiv.append(msg_html);
|
||||
}
|
||||
|
||||
$(divname).animate({scrollTop: $(divname)[0].scrollHeight}, "slow");
|
||||
}
|
||||
|
||||
function create_message_html(time, from, to, message, ack) {
|
||||
msg_html = '<div class="item">';
|
||||
msg_html += '<div class="tiny text">'+time+'</div>';
|
||||
msg_html += '<div class="middle aligned content">';
|
||||
msg_html += '<div class="tiny red header">'+from+'</div>';
|
||||
if (ack) {
|
||||
msg_html += '<i class="thumbs down outline icon" id="' + ack_id + '" data-content="Waiting for ACK"></i>';
|
||||
} else {
|
||||
msg_html += '<i class="phone volume icon" data-content="Recieved Message"></i>';
|
||||
}
|
||||
msg_html += '<div class="middle aligned content">> </div>';
|
||||
msg_html += '</div>';
|
||||
msg_html += '<div class="middle aligned content">'+message+'</div>';
|
||||
msg_html += '</div><br>';
|
||||
|
||||
return msg_html
|
||||
}
|
||||
|
||||
function sent_msg(msg) {
|
||||
var msgsdiv = $("#sendMsgsDiv");
|
||||
|
||||
ts_str = msg["ts"].toString();
|
||||
ts = ts_str.split(".")[0]*1000;
|
||||
id = ts_str.split('.')[0]
|
||||
ack_id = "ack_" + id
|
||||
|
||||
var d = new Date(ts).toLocaleDateString("en-US")
|
||||
var t = new Date(ts).toLocaleTimeString("en-US")
|
||||
|
||||
msg_html = create_message_html(t, msg['from'], msg['to'], msg['message'], ack_id);
|
||||
append_message(msg['to'], msg, msg_html);
|
||||
}
|
||||
|
||||
function from_msg(msg) {
|
||||
var msgsdiv = $("#sendMsgsDiv");
|
||||
|
||||
// We have an existing entry
|
||||
ts_str = msg["ts"].toString();
|
||||
ts = ts_str.split(".")[0]*1000;
|
||||
id = ts_str.split('.')[0]
|
||||
ack_id = "ack_" + id
|
||||
|
||||
var d = new Date(ts).toLocaleDateString("en-US")
|
||||
var t = new Date(ts).toLocaleTimeString("en-US")
|
||||
|
||||
from = msg['from']
|
||||
msg_html = create_message_html(t, from, false, msg['message'], false);
|
||||
append_message(from, msg, msg_html);
|
||||
}
|
||||
|
||||
function update_msg(msg) {
|
||||
var msgsdiv = $("#sendMsgsDiv");
|
||||
// We have an existing entry
|
||||
ts_str = msg["ts"].toString();
|
||||
id = ts_str.split('.')[0]
|
||||
pretty_id = "pretty_" + id
|
||||
loader_id = "loader_" + id
|
||||
ack_id = "ack_" + id
|
||||
span_id = "span_" + id
|
||||
|
||||
|
||||
|
||||
if (msg['ack'] == true) {
|
||||
var loader_div = $('#' + loader_id);
|
||||
var ack_div = $('#' + ack_id);
|
||||
loader_div.removeClass('ui active inline loader');
|
||||
loader_div.addClass('ui disabled loader');
|
||||
ack_div.removeClass('thumbs up outline icon');
|
||||
ack_div.addClass('thumbs up outline icon');
|
||||
}
|
||||
|
||||
$('.ui.accordion').accordion('refresh');
|
||||
}
|
||||
|
||||
function callsign_select(callsign) {
|
||||
var tocall = $("#to_call");
|
||||
tocall.val(callsign);
|
||||
}
|
||||
|
||||
function reset_Tabs() {
|
||||
tabcontent = document.getElementsByClassName("tabcontent");
|
||||
for (i = 0; i < tabcontent.length; i++) {
|
||||
tabcontent[i].style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function openCallsign(evt, callsign) {
|
||||
var i, tabcontent, tablinks;
|
||||
|
||||
tab_content = tab_content_name(callsign);
|
||||
|
||||
tabcontent = document.getElementsByClassName("tabcontent");
|
||||
for (i = 0; i < tabcontent.length; i++) {
|
||||
tabcontent[i].style.display = "none";
|
||||
}
|
||||
tablinks = document.getElementsByClassName("tablinks");
|
||||
for (i = 0; i < tablinks.length; i++) {
|
||||
tablinks[i].className = tablinks[i].className.replace(" active", "");
|
||||
}
|
||||
document.getElementById(tab_content).style.display = "block";
|
||||
evt.target.className += " active";
|
||||
callsign_select(callsign);
|
||||
}
|
28
aprsd/web/chat/static/js/tabs.js
Normal file
@ -0,0 +1,28 @@
|
||||
function openTab(evt, tabName) {
|
||||
// Declare all variables
|
||||
var i, tabcontent, tablinks;
|
||||
|
||||
if (typeof tabName == 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
// Get all elements with class="tabcontent" and hide them
|
||||
tabcontent = document.getElementsByClassName("tabcontent");
|
||||
for (i = 0; i < tabcontent.length; i++) {
|
||||
tabcontent[i].style.display = "none";
|
||||
}
|
||||
|
||||
// Get all elements with class="tablinks" and remove the class "active"
|
||||
tablinks = document.getElementsByClassName("tablinks");
|
||||
for (i = 0; i < tablinks.length; i++) {
|
||||
tablinks[i].className = tablinks[i].className.replace(" active", "");
|
||||
}
|
||||
|
||||
// Show the current tab, and add an "active" class to the button that opened the tab
|
||||
document.getElementById(tabName).style.display = "block";
|
||||
if (typeof evt.currentTarget == 'undefined') {
|
||||
return
|
||||
} else {
|
||||
evt.currentTarget.className += " active";
|
||||
}
|
||||
}
|
57
aprsd/web/chat/static/json-viewer/jquery.json-viewer.css
Normal file
@ -0,0 +1,57 @@
|
||||
/* Root element */
|
||||
.json-document {
|
||||
padding: 1em 2em;
|
||||
}
|
||||
|
||||
/* Syntax highlighting for JSON objects */
|
||||
ul.json-dict, ol.json-array {
|
||||
list-style-type: none;
|
||||
margin: 0 0 0 1px;
|
||||
border-left: 1px dotted #ccc;
|
||||
padding-left: 2em;
|
||||
}
|
||||
.json-string {
|
||||
color: #0B7500;
|
||||
}
|
||||
.json-literal {
|
||||
color: #1A01CC;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Toggle button */
|
||||
a.json-toggle {
|
||||
position: relative;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
a.json-toggle:focus {
|
||||
outline: none;
|
||||
}
|
||||
a.json-toggle:before {
|
||||
font-size: 1.1em;
|
||||
color: #c0c0c0;
|
||||
content: "\25BC"; /* down arrow */
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
text-align: center;
|
||||
line-height: 1em;
|
||||
left: -1.2em;
|
||||
}
|
||||
a.json-toggle:hover:before {
|
||||
color: #aaa;
|
||||
}
|
||||
a.json-toggle.collapsed:before {
|
||||
/* Use rotated down arrow, prevents right arrow appearing smaller than down arrow in some browsers */
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* Collapsable placeholder links */
|
||||
a.json-placeholder {
|
||||
color: #aaa;
|
||||
padding: 0 1em;
|
||||
text-decoration: none;
|
||||
}
|
||||
a.json-placeholder:hover {
|
||||
text-decoration: underline;
|
||||
}
|
158
aprsd/web/chat/static/json-viewer/jquery.json-viewer.js
Normal file
@ -0,0 +1,158 @@
|
||||
/**
|
||||
* jQuery json-viewer
|
||||
* @author: Alexandre Bodelot <alexandre.bodelot@gmail.com>
|
||||
* @link: https://github.com/abodelot/jquery.json-viewer
|
||||
*/
|
||||
(function($) {
|
||||
|
||||
/**
|
||||
* Check if arg is either an array with at least 1 element, or a dict with at least 1 key
|
||||
* @return boolean
|
||||
*/
|
||||
function isCollapsable(arg) {
|
||||
return arg instanceof Object && Object.keys(arg).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string represents a valid url
|
||||
* @return boolean
|
||||
*/
|
||||
function isUrl(string) {
|
||||
var urlRegexp = /^(https?:\/\/|ftps?:\/\/)?([a-z0-9%-]+\.){1,}([a-z0-9-]+)?(:(\d{1,5}))?(\/([a-z0-9\-._~:/?#[\]@!$&'()*+,;=%]+)?)?$/i;
|
||||
return urlRegexp.test(string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a json object into html representation
|
||||
* @return string
|
||||
*/
|
||||
function json2html(json, options) {
|
||||
var html = '';
|
||||
if (typeof json === 'string') {
|
||||
// Escape tags and quotes
|
||||
json = json
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
if (options.withLinks && isUrl(json)) {
|
||||
html += '<a href="' + json + '" class="json-string" target="_blank">' + json + '</a>';
|
||||
} else {
|
||||
// Escape double quotes in the rendered non-URL string.
|
||||
json = json.replace(/"/g, '\\"');
|
||||
html += '<span class="json-string">"' + json + '"</span>';
|
||||
}
|
||||
} else if (typeof json === 'number') {
|
||||
html += '<span class="json-literal">' + json + '</span>';
|
||||
} else if (typeof json === 'boolean') {
|
||||
html += '<span class="json-literal">' + json + '</span>';
|
||||
} else if (json === null) {
|
||||
html += '<span class="json-literal">null</span>';
|
||||
} else if (json instanceof Array) {
|
||||
if (json.length > 0) {
|
||||
html += '[<ol class="json-array">';
|
||||
for (var i = 0; i < json.length; ++i) {
|
||||
html += '<li>';
|
||||
// Add toggle button if item is collapsable
|
||||
if (isCollapsable(json[i])) {
|
||||
html += '<a href class="json-toggle"></a>';
|
||||
}
|
||||
html += json2html(json[i], options);
|
||||
// Add comma if item is not last
|
||||
if (i < json.length - 1) {
|
||||
html += ',';
|
||||
}
|
||||
html += '</li>';
|
||||
}
|
||||
html += '</ol>]';
|
||||
} else {
|
||||
html += '[]';
|
||||
}
|
||||
} else if (typeof json === 'object') {
|
||||
var keyCount = Object.keys(json).length;
|
||||
if (keyCount > 0) {
|
||||
html += '{<ul class="json-dict">';
|
||||
for (var key in json) {
|
||||
if (Object.prototype.hasOwnProperty.call(json, key)) {
|
||||
html += '<li>';
|
||||
var keyRepr = options.withQuotes ?
|
||||
'<span class="json-string">"' + key + '"</span>' : key;
|
||||
// Add toggle button if item is collapsable
|
||||
if (isCollapsable(json[key])) {
|
||||
html += '<a href class="json-toggle">' + keyRepr + '</a>';
|
||||
} else {
|
||||
html += keyRepr;
|
||||
}
|
||||
html += ': ' + json2html(json[key], options);
|
||||
// Add comma if item is not last
|
||||
if (--keyCount > 0) {
|
||||
html += ',';
|
||||
}
|
||||
html += '</li>';
|
||||
}
|
||||
}
|
||||
html += '</ul>}';
|
||||
} else {
|
||||
html += '{}';
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* jQuery plugin method
|
||||
* @param json: a javascript object
|
||||
* @param options: an optional options hash
|
||||
*/
|
||||
$.fn.jsonViewer = function(json, options) {
|
||||
// Merge user options with default options
|
||||
options = Object.assign({}, {
|
||||
collapsed: false,
|
||||
rootCollapsable: true,
|
||||
withQuotes: false,
|
||||
withLinks: true
|
||||
}, options);
|
||||
|
||||
// jQuery chaining
|
||||
return this.each(function() {
|
||||
|
||||
// Transform to HTML
|
||||
var html = json2html(json, options);
|
||||
if (options.rootCollapsable && isCollapsable(json)) {
|
||||
html = '<a href class="json-toggle"></a>' + html;
|
||||
}
|
||||
|
||||
// Insert HTML in target DOM element
|
||||
$(this).html(html);
|
||||
$(this).addClass('json-document');
|
||||
|
||||
// Bind click on toggle buttons
|
||||
$(this).off('click');
|
||||
$(this).on('click', 'a.json-toggle', function() {
|
||||
var target = $(this).toggleClass('collapsed').siblings('ul.json-dict, ol.json-array');
|
||||
target.toggle();
|
||||
if (target.is(':visible')) {
|
||||
target.siblings('.json-placeholder').remove();
|
||||
} else {
|
||||
var count = target.children('li').length;
|
||||
var placeholder = count + (count > 1 ? ' items' : ' item');
|
||||
target.after('<a href class="json-placeholder">' + placeholder + '</a>');
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Simulate click on toggle button when placeholder is clicked
|
||||
$(this).on('click', 'a.json-placeholder', function() {
|
||||
$(this).siblings('a.json-toggle').click();
|
||||
return false;
|
||||
});
|
||||
|
||||
if (options.collapsed == true) {
|
||||
// Trigger click to collapse all nodes
|
||||
$(this).find('a.json-toggle').click();
|
||||
}
|
||||
});
|
||||
};
|
||||
})(jQuery);
|
86
aprsd/web/chat/templates/index.html
Normal file
@ -0,0 +1,86 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1">
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
|
||||
<link rel="stylesheet" href="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/smoothness/jquery-ui.css">
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
|
||||
<script src="https://cdn.socket.io/4.1.2/socket.io.min.js" integrity="sha384-toS6mmwu70G0fw54EGlWWeA4z3dyJ+dlXBtSURSKN4vyRFOcxd3Bzjj/AoOwY+Rg" crossorigin="anonymous"></script>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.js"></script>
|
||||
|
||||
<link rel="stylesheet" href="/static/css/index.css">
|
||||
<link rel="stylesheet" href="/static/css/tabs.css">
|
||||
<script src="/static/js/main.js"></script>
|
||||
<script src="/static/js/send-message.js"></script>
|
||||
|
||||
|
||||
<script type="text/javascript">
|
||||
var initial_stats = {{ initial_stats|tojson|safe }};
|
||||
|
||||
var memory_chart = null
|
||||
var message_chart = null
|
||||
|
||||
$(document).ready(function() {
|
||||
console.log(initial_stats);
|
||||
start_update();
|
||||
init_chat();
|
||||
reset_Tabs();
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='ui text container'>
|
||||
<h1 class='ui dividing header'>APRSD WebChat {{ version }}</h1>
|
||||
</div>
|
||||
|
||||
<div class='ui grid text container'>
|
||||
<div class='left floated ten wide column'>
|
||||
<span style='color: green'>{{ callsign }}</span>
|
||||
connected to
|
||||
<span style='color: blue' id='aprs_connection'>{{ aprs_connection|safe }}</span>
|
||||
</div>
|
||||
|
||||
<div class='right floated four wide column'>
|
||||
<span id='uptime'>NONE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui container">
|
||||
<h3 class="ui dividing header">Send Message</h3>
|
||||
<div id="sendMsgDiv" class="ui mini text">
|
||||
<form id="sendform" name="sendmsg" action="">
|
||||
<div class="ui corner labeled input">
|
||||
<label for="to_call" class="ui label">Callsign</label>
|
||||
<input type="text" name="to_call" id="to_call" placeholder="To Callsign" size="11" maxlength="9">
|
||||
<div class="ui corner label">
|
||||
<i class="asterisk icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui labeled input">
|
||||
<label for="message" class="ui label">Message</label>
|
||||
<input type="text" name="message" id="message" size="40" maxlength="40">
|
||||
</div>
|
||||
<input type="submit" name="submit" class="ui button" id="send_msg" value="Send" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui grid">
|
||||
<div class="three wide column">
|
||||
<div class="tab" id="callsignTabs">
|
||||
</div>
|
||||
</div>
|
||||
<div class="ten wide column ui raised segment" id="msgsTabsDiv" style="height:450px;padding:0px;">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui text container" style="padding-top: 40px">
|
||||
<a href="https://badge.fury.io/py/aprsd"><img src="https://badge.fury.io/py/aprsd.svg" alt="PyPI version" height="18"></a>
|
||||
<a href="https://github.com/craigerl/aprsd"><img src="https://img.shields.io/badge/Made%20with-Python-1f425f.svg" height="18"></a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
@ -24,3 +24,4 @@ tabulate
|
||||
rich
|
||||
# For the list-plugins pypi.org search scraping
|
||||
beautifulsoup4
|
||||
wrapt
|
||||
|
@ -128,3 +128,5 @@ weakrefmethod==1.0.3
|
||||
# via signalslot
|
||||
werkzeug==2.0.0
|
||||
# via flask
|
||||
wrapt==1.14.1
|
||||
# via -r requirements.in
|
||||
|
@ -15,7 +15,10 @@ F = t.TypeVar("F", bound=t.Callable[..., t.Any])
|
||||
class TestDevTestPluginCommand(unittest.TestCase):
|
||||
|
||||
def _build_config(self, login=None, password=None):
|
||||
config = {"aprs": {}}
|
||||
config = {
|
||||
"aprs": {},
|
||||
"aprsd": {"trace": False},
|
||||
}
|
||||
if login:
|
||||
config["aprs"]["login"] = login
|
||||
|
||||
|
@ -15,7 +15,10 @@ F = t.TypeVar("F", bound=t.Callable[..., t.Any])
|
||||
class TestSendMessageCommand(unittest.TestCase):
|
||||
|
||||
def _build_config(self, login=None, password=None):
|
||||
config = {"aprs": {}}
|
||||
config = {
|
||||
"aprs": {},
|
||||
"aprsd": {"trace": False},
|
||||
}
|
||||
if login:
|
||||
config["aprs"]["login"] = login
|
||||
|
||||
|