1
0
mirror of https://github.com/craigerl/aprsd.git synced 2026-08-17 17:13:49 -04:00

Compare commits

..

7 Commits

Author SHA1 Message Date
hemna 691b18fd1c Prep for v2.1.0
Update the Changelog for v2.1.0
2021-08-13 13:11:49 -04:00
hemna 911730b28a Merge pull request #68 from craigerl/plugins_multiple_msgs
Enable multiple replies for plugins
2021-08-13 12:45:52 -04:00
hemna 349250685b Enable multiple replies for plugins
This patch adds the ability for plugins to send multiple messages
back in response to a command/message.  The plugin simple needs
to return a list of messages (Strings).  Each string in that list
will result in a separate message being sent back to the originator
of the message.
2021-08-13 12:36:48 -04:00
hemna 840c8a990e Put in a fix for aprslib parse exceptions
This patch adds a fix for the aprslib consumer function
to ensure that we don't bail when logging a ParseError
2021-08-13 10:31:45 -04:00
hemna ed4995b6eb Fixed time plugin 2021-07-29 20:17:58 -04:00
hemna 6740ff80be Updated the charts Added the packets chart
This patch adds the APRS Packets chart to the charts admin ui.
Also moves the raw json as it's own tab
2021-07-22 20:44:20 -04:00
hemna be8179415a Added showing symbol images to watch list
This patch updates the Admin UI to display the APRS icon symbol
associated with a mic-e packet on the watch list tab for all
entries in the watch list.
2021-07-21 09:21:04 -04:00
16 changed files with 391 additions and 145 deletions
+10
View File
@@ -1,9 +1,19 @@
CHANGES
=======
v2.1.0
------
* Enable multiple replies for plugins
* Put in a fix for aprslib parse exceptions
* Fixed time plugin
* Updated the charts Added the packets chart
* Added showing symbol images to watch list
v2.0.0
------
* Updated docs for 2.0.0
* Reworked the notification threads and admin ui
* Fixed small bug with packets get\_packet\_type
* Updated overview images
+13 -3
View File
@@ -224,11 +224,21 @@ class Aprsdis(aprslib.IS):
self.logger.debug("Server: %s", line.decode("utf8"))
stats.APRSDStats().set_aprsis_keepalive()
except ParseError as exp:
self.logger.log(11, "%s\n Packet: %s", exp.args[0], exp.args[1])
self.logger.log(
11,
"%s\n Packet: %s",
exp,
exp.packet,
)
except UnknownFormat as exp:
self.logger.log(9, "unknown format %s", exp.args)
self.logger.log(
9,
"%s\n Packet: %s",
exp,
exp.packet,
)
except LoginError as exp:
self.logger.error("%s: %s", exp.__class__.__name__, exp.args[0])
self.logger.error("%s: %s", exp.__class__.__name__, exp)
except (KeyboardInterrupt, SystemExit):
raise
except (ConnectionDrop, ConnectionError):
+8 -1
View File
@@ -121,6 +121,13 @@ class APRSDFlask(flask_classful.FlaskView):
}
stats_dict["aprsd"]["watch_list"] = new_list
packet_list = packets.PacketList()
rx = packet_list.total_received()
tx = packet_list.total_sent()
stats_dict["packets"] = {
"sent": tx,
"received": rx,
}
result = {
"time": now.strftime(time_format),
@@ -170,7 +177,7 @@ def setup_logging(config, flask_app, loglevel, quiet):
def init_flask(config, loglevel, quiet):
flask_app = flask.Flask(
"aprsd",
static_url_path="",
static_url_path="/static",
static_folder="web/static",
template_folder="web/templates",
)
+13 -1
View File
@@ -32,7 +32,18 @@ import time
# local imports here
import aprsd
from aprsd import client, email, flask, messaging, plugin, stats, threads, trace, utils
from aprsd import (
client,
email,
flask,
messaging,
packets,
plugin,
stats,
threads,
trace,
utils,
)
import aprslib
from aprslib.exceptions import LoginError
import click
@@ -519,6 +530,7 @@ def server(
"enabled",
True,
):
packets.PacketList(config)
notify_thread = threads.APRSDNotifyThread(
msg_queues=msg_queues,
config=config,
+18
View File
@@ -16,9 +16,13 @@ class PacketList:
"""Class to track all of the packets rx'd and tx'd by aprsd."""
_instance = None
config = None
packet_list = {}
total_recv = 0
total_tx = 0
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
@@ -26,6 +30,10 @@ class PacketList:
cls._instance.lock = threading.Lock()
return cls._instance
def __init__(self, config=None):
if config:
self.config = config
def __iter__(self):
with self.lock:
return iter(self.packet_list)
@@ -33,12 +41,22 @@ class PacketList:
def add(self, packet):
with self.lock:
packet["ts"] = time.time()
if "from" in packet and packet["from"] == self.config["aprs"]["login"]:
self.total_tx += 1
else:
self.total_recv += 1
self.packet_list.append(packet)
def get(self):
with self.lock:
return self.packet_list.get()
def total_received(self):
return self.total_recv
def total_sent(self):
return self.total_tx
class WatchList:
"""Global watch list and info for callsigns."""
+49 -6
View File
@@ -1,4 +1,5 @@
import logging
import re
import time
from aprsd import fuzzyclock, plugin, plugin_utils, trace, utils
@@ -56,17 +57,38 @@ class TimeOpenCageDataPlugin(TimePlugin):
@trace.trace
def command(self, packet):
fromcall = packet.get("from")
# message = packet.get("message_text", None)
message = packet.get("message_text", None)
# ack = packet.get("msgNo", "0")
api_key = self.config["services"]["aprs.fi"]["apiKey"]
# get last location of a callsign, get descriptive name from weather service
try:
aprs_data = plugin_utils.get_aprs_fi(api_key, fromcall)
utils.check_config_option(self.config, ["services", "aprs.fi", "apiKey"])
except Exception as ex:
LOG.error("Failed to find config aprs.fi:apikey {}".format(ex))
return "No aprs.fi apikey found"
api_key = self.config["services"]["aprs.fi"]["apiKey"]
# optional second argument is a callsign to search
a = re.search(r"^.*\s+(.*)", message)
if a is not None:
searchcall = a.group(1)
searchcall = searchcall.upper()
else:
# if no second argument, search for calling station
searchcall = fromcall
try:
aprs_data = plugin_utils.get_aprs_fi(api_key, searchcall)
except Exception as ex:
LOG.error("Failed to fetch aprs.fi data {}".format(ex))
return "Failed to fetch location"
# LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data))
if not len(aprs_data["entries"]):
LOG.error("Didn't get any entries from aprs.fi")
return "Failed to fetch aprs.fi location"
lat = aprs_data["entries"][0]["lat"]
lon = aprs_data["entries"][0]["lng"]
@@ -101,16 +123,37 @@ class TimeOWMPlugin(TimePlugin):
@trace.trace
def command(self, packet):
fromcall = packet.get("from")
# message = packet.get("message_text", None)
message = packet.get("message_text", None)
# ack = packet.get("msgNo", "0")
# get last location of a callsign, get descriptive name from weather service
try:
utils.check_config_option(self.config, ["services", "aprs.fi", "apiKey"])
except Exception as ex:
LOG.error("Failed to find config aprs.fi:apikey {}".format(ex))
return "No aprs.fi apikey found"
# optional second argument is a callsign to search
a = re.search(r"^.*\s+(.*)", message)
if a is not None:
searchcall = a.group(1)
searchcall = searchcall.upper()
else:
# if no second argument, search for calling station
searchcall = fromcall
api_key = self.config["services"]["aprs.fi"]["apiKey"]
try:
aprs_data = plugin_utils.get_aprs_fi(api_key, fromcall)
aprs_data = plugin_utils.get_aprs_fi(api_key, searchcall)
except Exception as ex:
LOG.error("Failed to fetch aprs.fi data {}".format(ex))
return "Failed to fetch location"
# LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data))
LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data))
if not len(aprs_data["entries"]):
LOG.error("Didn't get any entries from aprs.fi")
return "Failed to fetch aprs.fi location"
lat = aprs_data["entries"][0]["lat"]
lon = aprs_data["entries"][0]["lng"]
+27 -13
View File
@@ -253,21 +253,35 @@ class APRSDRXThread(APRSDThread):
try:
results = pm.run(packet)
for reply in results:
found_command = True
# A plugin can return a null message flag which signals
# us that they processed the message correctly, but have
# nothing to reply with, so we avoid replying with a usage string
if reply is not messaging.NULL_MESSAGE:
LOG.debug("Sending '{}'".format(reply))
if isinstance(reply, list):
# one of the plugins wants to send multiple messages
found_command = True
for subreply in reply:
LOG.debug("Sending '{}'".format(subreply))
msg = messaging.TextMessage(
self.config["aprs"]["login"],
fromcall,
subreply,
)
self.msg_queues["tx"].put(msg)
msg = messaging.TextMessage(
self.config["aprs"]["login"],
fromcall,
reply,
)
self.msg_queues["tx"].put(msg)
else:
LOG.debug("Got NULL MESSAGE from plugin")
found_command = True
# A plugin can return a null message flag which signals
# us that they processed the message correctly, but have
# nothing to reply with, so we avoid replying with a usage string
if reply is not messaging.NULL_MESSAGE:
LOG.debug("Sending '{}'".format(reply))
msg = messaging.TextMessage(
self.config["aprs"]["login"],
fromcall,
reply,
)
self.msg_queues["tx"].put(msg)
else:
LOG.debug("Got NULL MESSAGE from plugin")
if not found_command:
plugins = pm.get_msg_plugins()
+9 -1
View File
@@ -38,7 +38,7 @@ footer {
#center {
height: 300px;
}
#messageChart, #emailChart, #memChart {
#packetsChart, #messageChart, #emailChart, #memChart {
border: 1px solid #ccc;
background: #ddd;
}
@@ -74,3 +74,11 @@ footer {
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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+57 -106
View File
@@ -8,7 +8,10 @@ window.chartColors = {
blue: 'rgb(54, 162, 235)',
purple: 'rgb(153, 102, 255)',
grey: 'rgb(201, 203, 207)',
black: 'rgb(0, 0, 0)'
black: 'rgb(0, 0, 0)',
lightcoral: 'rgb(240,128,128)',
darkseagreen: 'rgb(143, 188,143)'
};
function size_dict(d){c=0; for (i in d) ++c; return c}
@@ -20,28 +23,28 @@ function start_charts() {
}
});
memory_chart = new Chart($("#memChart"), {
label: 'Memory Usage',
packets_chart = new Chart($("#packetsChart"), {
label: 'APRS Packets',
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Peak Ram usage',
borderColor: window.chartColors.red,
label: 'Packets Sent',
borderColor: window.chartColors.lightcoral,
data: [],
},
{
label: 'Current Ram usage',
borderColor: window.chartColors.blue,
label: 'Packets Recieved',
borderColor: window.chartColors.darkseagreen,
data: [],
}],
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
title: {
display: true,
text: 'Memory Usage',
text: 'APRS Packets',
},
scales: {
x: {
@@ -67,12 +70,12 @@ function start_charts() {
labels: [],
datasets: [{
label: 'Messages Sent',
borderColor: window.chartColors.green,
borderColor: window.chartColors.lightcoral,
data: [],
},
{
label: 'Messages Recieved',
borderColor: window.chartColors.yellow,
borderColor: window.chartColors.darkseagreen,
data: [],
},
{
@@ -117,12 +120,12 @@ function start_charts() {
labels: [],
datasets: [{
label: 'Sent',
borderColor: window.chartColors.green,
borderColor: window.chartColors.lightcoral,
data: [],
},
{
label: 'Recieved',
borderColor: window.chartColors.yellow,
borderColor: window.chartColors.darkseagreen,
data: [],
}],
},
@@ -149,6 +152,46 @@ function start_charts() {
}
}
});
memory_chart = new Chart($("#memChart"), {
label: 'Memory Usage',
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Peak Ram usage',
borderColor: window.chartColors.red,
data: [],
},
{
label: 'Current Ram usage',
borderColor: window.chartColors.blue,
data: [],
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
title: {
display: true,
text: 'Memory Usage',
},
scales: {
x: {
type: 'timeseries',
offset: true,
ticks: {
major: { enabled: true },
fontStyle: context => context.tick.major ? 'bold' : undefined,
source: 'data',
maxRotation: 0,
autoSkip: true,
autoSkipPadding: 75,
}
}
}
}
});
}
@@ -182,100 +225,8 @@ function update_stats( data ) {
const html_pretty = Prism.highlight(JSON.stringify(data, null, '\t'), Prism.languages.json, 'json');
$("#jsonstats").html(html_pretty);
short_time = data["time"].split(/\s(.+)/)[1];
updateDualData(packets_chart, short_time, data["stats"]["packets"]["sent"], data["stats"]["packets"]["received"]);
updateQuadData(message_chart, short_time, data["stats"]["messages"]["sent"], data["stats"]["messages"]["recieved"], data["stats"]["messages"]["ack_sent"], data["stats"]["messages"]["ack_recieved"]);
updateDualData(email_chart, short_time, data["stats"]["email"]["sent"], data["stats"]["email"]["recieved"]);
updateDualData(memory_chart, short_time, data["stats"]["aprsd"]["memory_peak"], data["stats"]["aprsd"]["memory_current"]);
// Update the watch list
var watchdiv = $("#watchDiv");
var html_str = '<table class="ui celled striped table"><thead><tr><th>HAM Callsign</th><th>Age since last seen by APRSD</th></tr></thead><tbody>'
watchdiv.html('')
jQuery.each(data["stats"]["aprsd"]["watch_list"], function(i, val) {
html_str += '<tr><td class="collapsing"><i class="phone volume icon"></i>' + i + '</td><td>' + val["last"] + '</td></tr>'
});
html_str += "</tbody></table>";
watchdiv.append(html_str);
}
function update_packets( data ) {
var packetsdiv = $("#packetsDiv");
//nuke the contents first, then add to it.
if (size_dict(packet_list) == 0 && size_dict(data) > 0) {
packetsdiv.html('')
}
jQuery.each(data, function(i, val) {
if ( packet_list.hasOwnProperty(val["ts"]) == false ) {
// Store the packet
packet_list[val["ts"]] = val;
ts_str = val["ts"].toString();
ts = ts_str.split(".")[0]*1000;
var d = new Date(ts).toLocaleDateString("en-US")
var t = new Date(ts).toLocaleTimeString("en-US")
if (val.hasOwnProperty('from') == false) {
from = val['fromcall']
title_id = 'title_tx'
} else {
from = val['from']
title_id = 'title_rx'
}
var from_to = d + " " + t + "&nbsp;&nbsp;&nbsp;&nbsp;" + from + " > "
if (val.hasOwnProperty('addresse')) {
from_to = from_to + val['addresse']
} else if (val.hasOwnProperty('tocall')) {
from_to = from_to + val['tocall']
} else if (val.hasOwnProperty('format') && val['format'] == 'mic-e') {
from_to = from_to + "Mic-E"
}
from_to = from_to + "&nbsp;&nbsp;-&nbsp;&nbsp;" + val['raw']
json_pretty = Prism.highlight(JSON.stringify(val, null, '\t'), Prism.languages.json, 'json');
pkt_html = '<div class="title" id="' + title_id + '"><i class="dropdown icon"></i>' + from_to + '</div><div class="content"><p class="transition hidden"><pre class="language-json">' + json_pretty + '</p></p></div>'
packetsdiv.prepend(pkt_html);
}
});
$('.ui.accordion').accordion('refresh');
// Update the count of messages shown
cnt = size_dict(packet_list);
console.log("packets list " + cnt)
$('#packets_count').html(cnt);
const html_pretty = Prism.highlight(JSON.stringify(data, null, '\t'), Prism.languages.json, 'json');
$("#packetsjson").html(html_pretty);
}
function start_update() {
(function statsworker() {
$.ajax({
url: "/stats",
type: 'GET',
dataType: 'json',
success: function(data) {
update_stats(data);
},
complete: function() {
setTimeout(statsworker, 10000);
}
});
})();
(function packetsworker() {
$.ajax({
url: "/packets",
type: 'GET',
dataType: 'json',
success: function(data) {
update_packets(data);
},
complete: function() {
setTimeout(packetsworker, 10000);
}
});
})();
}
+143
View File
@@ -0,0 +1,143 @@
// watchlist is a dict of ham callsign => symbol, packets
var watchlist = {};
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_watchlist( data ) {
// Update the watch list
var watchdiv = $("#watchDiv");
var html_str = '<table class="ui celled striped table"><thead><tr><th>HAM Callsign</th><th>Age since last seen by APRSD</th></tr></thead><tbody>'
watchdiv.html('')
jQuery.each(data["stats"]["aprsd"]["watch_list"], function(i, val) {
html_str += '<tr><td class="collapsing"><img id="callsign_'+i+'" class="aprsd_1"></img>' + i + '</td><td>' + val["last"] + '</td></tr>'
});
html_str += "</tbody></table>";
watchdiv.append(html_str);
jQuery.each(watchlist, function(i, val) {
//update the symbol
var call_img = $('#callsign_'+i);
show_aprs_icon(call_img, val['symbol'])
});
}
function update_watchlist_from_packet(callsign, val) {
if (!watchlist.hasOwnProperty(callsign)) {
watchlist[callsign] = {
"symbol": '[',
"packets": {},
}
} else {
if (val.hasOwnProperty('symbol')) {
//console.log("Updating symbol for "+callsign + " to "+val["symbol"])
watchlist[callsign]["symbol"] = val["symbol"]
}
}
if (watchlist[callsign]["packets"].hasOwnProperty(val['ts']) == false) {
watchlist[callsign]["packets"][val['ts']]= val;
}
//console.log(watchlist)
}
function update_packets( data ) {
var packetsdiv = $("#packetsDiv");
//nuke the contents first, then add to it.
if (size_dict(packet_list) == 0 && size_dict(data) > 0) {
packetsdiv.html('')
}
jQuery.each(data, function(i, val) {
update_watchlist_from_packet(val['from'], val);
if ( packet_list.hasOwnProperty(val["ts"]) == false ) {
// Store the packet
packet_list[val["ts"]] = val;
ts_str = val["ts"].toString();
ts = ts_str.split(".")[0]*1000;
var d = new Date(ts).toLocaleDateString("en-US")
var t = new Date(ts).toLocaleTimeString("en-US")
if (val.hasOwnProperty('from') == false) {
from = val['fromcall']
title_id = 'title_tx'
} else {
from = val['from']
title_id = 'title_rx'
}
var from_to = d + " " + t + "&nbsp;&nbsp;&nbsp;&nbsp;" + from + " > "
if (val.hasOwnProperty('addresse')) {
from_to = from_to + val['addresse']
} else if (val.hasOwnProperty('tocall')) {
from_to = from_to + val['tocall']
} else if (val.hasOwnProperty('format') && val['format'] == 'mic-e') {
from_to = from_to + "Mic-E"
}
from_to = from_to + "&nbsp;&nbsp;-&nbsp;&nbsp;" + val['raw']
json_pretty = Prism.highlight(JSON.stringify(val, null, '\t'), Prism.languages.json, 'json');
pkt_html = '<div class="title" id="' + title_id + '"><i class="dropdown icon"></i>' + from_to + '</div><div class="content"><p class="transition hidden"><pre class="language-json">' + json_pretty + '</p></p></div>'
packetsdiv.prepend(pkt_html);
}
});
$('.ui.accordion').accordion('refresh');
// Update the count of messages shown
cnt = size_dict(packet_list);
//console.log("packets list " + cnt)
$('#packets_count').html(cnt);
const html_pretty = Prism.highlight(JSON.stringify(data, null, '\t'), Prism.languages.json, 'json');
$("#packetsjson").html(html_pretty);
}
function start_update() {
(function statsworker() {
$.ajax({
url: "/stats",
type: 'GET',
dataType: 'json',
success: function(data) {
update_stats(data);
update_watchlist(data);
},
complete: function() {
setTimeout(statsworker, 10000);
}
});
})();
(function packetsworker() {
$.ajax({
url: "/packets",
type: 'GET',
dataType: 'json',
success: function(data) {
update_packets(data);
},
complete: function() {
setTimeout(packetsworker, 10000);
}
});
})();
}
+44 -14
View File
@@ -12,10 +12,11 @@
<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="/css/index.css">
<link rel="stylesheet" href="/css/tabs.css">
<script src="/js/charts.js"></script>
<script src="/js/tabs.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/charts.js"></script>
<script src="/static/js/tabs.js"></script>
<script type="text/javascript"">
@@ -40,6 +41,7 @@
var cfg_pretty = JSON.stringify(cfg_json, null, '\t');
const html_pretty = Prism.highlight( cfg_pretty, Prism.languages.json, 'json');
$("#configjson").html(html_pretty);
$("#jsonstats").fadeToggle(1000);
$('.ui.accordion').accordion({exclusive: false});
$('.menu .item').tab('change tab', 'charts-tab');
@@ -70,22 +72,45 @@
<div class="item" data-tab="msgs-tab">Messages</div>
<div class="item" data-tab="watch-tab">Watch List</div>
<div class="item" data-tab="config-tab">Config</div>
<div class="item" data-tab="raw-tab">Raw JSON</div>
</div>
<!-- Tab content -->
<div class="ui bottom attached active tab segment" data-tab="charts-tab">
<h3 class="ui dividing header">Charts</h3>
<div id="graphs">
<div id="left"><canvas id="messageChart"></canvas></div>
<div id="right"><canvas class="right" id="emailChart"></canvas></div>
</div>
<div id="graphs_center">
<div id="center"><canvas id="memChart"></canvas></div>
</div>
<div id="stats">
<button class="ui button" id="toggleStats">Toggle raw json</button>
<pre id="jsonstats" class="language-json">{{ stats }}</pre>
<div class="ui equal width relaxed grid">
<div class="row">
<div class="column">
<div class="ui segment" style="height: 300px">
<canvas id="packetsChart"></canvas>
</div>
</div>
<div class="column">
<div class="ui segment" style="height: 300px">
<canvas id="messageChart"></canvas>
</div>
</div>
</div>
<div class="row">
<div class="column">
<div class="ui segment" style="height: 300px">
<canvas id="emailChart"></canvas>
</div>
</div>
<div class="column">
<div class="ui segment" style="height: 300px">
<canvas id="memChart"></canvas>
</div>
</div>
</div>
<!-- <div class="row">
<div id="stats" class="two column">
<button class="ui button" id="toggleStats">Toggle raw json</button>
<pre id="jsonstats" class="language-json">{{ stats }}</pre>
</div> --!>
</div>
</div>
</div>
<div class="ui bottom attached tab segment" data-tab="msgs-tab">
@@ -109,6 +134,11 @@
<pre id="configjson" class="language-json">{{ config_json|safe }}</pre>
</div>
<div class="ui bottom attached tab segment" data-tab="raw-tab">
<h3 class="ui dividing header">Raw JSON</h3>
<pre id="jsonstats" class="language-json">{{ stats|safe }}</pre>
</div>
<div class="ui text container">
<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>