Added Location info on webchat interface

This patch adds a new popover in the webchat tab to show
the location information for a callsign.

webchat will try to hit aprs.fi to fetch the location from the
callsign's last beacon.  If there is no internet, this will fail
and webchat will send a request to REPEAT callsign for the location
information.
This commit is contained in:
Hemna 2024-02-06 13:50:51 -05:00
parent 03c58f83cd
commit d6f0f05315
4 changed files with 315 additions and 24 deletions

View File

@ -2,6 +2,7 @@ import datetime
import json
import logging
from logging.handlers import RotatingFileHandler
import math
import signal
import sys
import threading
@ -14,15 +15,17 @@ from flask import request
from flask.logging import default_handler
from flask_httpauth import HTTPBasicAuth
from flask_socketio import Namespace, SocketIO
from geopy.distance import geodesic
from oslo_config import cfg
from werkzeug.security import check_password_hash, generate_password_hash
import wrapt
import aprsd
from aprsd import cli_helper, client, conf, packets, stats, threads, utils
from aprsd import cli_helper, client, conf, packets, stats, threads, utils, plugin_utils
from aprsd.log import rich as aprsd_logging
from aprsd.main import cli
from aprsd.threads import rx, tx
from aprsd.threads import aprsd as aprsd_threads
from aprsd.utils import trace
@ -32,6 +35,16 @@ auth = HTTPBasicAuth()
users = {}
socketio = None
# List of callsigns that we don't want to track/fetch their location
callsign_no_track = [
"REPEAT", "WB4BOR-11", "APDW16", "WXNOW", "WXBOT", "BLN0", "BLN1", "BLN2",
"BLN3", "BLN4", "BLN5", "BLN6", "BLN7", "BLN8", "BLN9",
]
# Callsign location information
# callsign: {lat: 0.0, long: 0.0, last_update: datetime}
callsign_locations = {}
flask_app = flask.Flask(
"aprsd",
static_url_path="/static",
@ -121,8 +134,186 @@ def verify_password(username, password):
return username
def calculate_initial_compass_bearing(point_a, point_b):
"""
Calculates the bearing between two points.
The formulae used is the following:
θ = atan2(sin(Δlong).cos(lat2),
cos(lat1).sin(lat2) sin(lat1).cos(lat2).cos(Δlong))
:Parameters:
- `pointA: The tuple representing the latitude/longitude for the
first point. Latitude and longitude must be in decimal degrees
- `pointB: The tuple representing the latitude/longitude for the
second point. Latitude and longitude must be in decimal degrees
:Returns:
The bearing in degrees
:Returns Type:
float
"""
if (type(point_a) is not tuple) or (type(point_b) is not tuple):
raise TypeError("Only tuples are supported as arguments")
lat1 = math.radians(point_a[0])
lat2 = math.radians(point_b[0])
diff_long = math.radians(point_b[1] - point_a[1])
x = math.sin(diff_long) * math.cos(lat2)
y = math.cos(lat1) * math.sin(lat2) - (
math.sin(lat1)
* math.cos(lat2) * math.cos(diff_long)
)
initial_bearing = math.atan2(x, y)
# Now we have the initial bearing but math.atan2 return values
# from -180° to + 180° which is not what we want for a compass bearing
# The solution is to normalize the initial bearing as shown below
initial_bearing = math.degrees(initial_bearing)
compass_bearing = (initial_bearing + 360) % 360
return compass_bearing
def _build_location_from_repeat(message):
# This is a location message Format is
# ^ld^callsign:latitude,longitude,altitude,course,speed,timestamp
a = message.split(":")
LOG.warning(a)
if len(a) == 2:
callsign = a[0].replace('^ld^', '')
b = a[1].split(",")
LOG.warning(b)
if len(b) == 6:
lat = float(b[0])
lon = float(b[1])
alt = float(b[2])
course = float(b[3])
speed = float(b[4])
time = int(b[5])
data = {
"callsign": callsign,
"lat": lat,
"lon": lon,
"altitude": alt,
"course": course,
"speed": speed,
"lasttime": time,
}
LOG.warning(f"Location data from REPEAT {data}")
return data
def _calculate_location_data(location_data):
"""Calculate all of the location data from data from aprs.fi or REPEAT."""
lat = location_data["lat"]
lon = location_data["lon"]
alt = location_data["altitude"]
speed = location_data["speed"]
lasttime = location_data["lasttime"]
# now calculate distance from our own location
distance = 0
if CONF.webchat.latitude and CONF.webchat.longitude:
our_lat = float(CONF.webchat.latitude)
our_lon = float(CONF.webchat.longitude)
distance = geodesic((our_lat, our_lon), (lat, lon)).kilometers
bearing = calculate_initial_compass_bearing(
(our_lat, our_lon),
(lat, lon),
)
return {
"callsign": location_data["callsign"],
"lat": lat,
"lon": lon,
"altitude": alt,
"course": f"{bearing:0.1f}",
"speed": speed,
"lasttime": lasttime,
"distance": f"{distance:0.3f}",
}
def send_location_data_to_browser(location_data):
global socketio
callsign = location_data["callsign"]
LOG.info(f"Got location for {callsign} {callsign_locations[callsign]}")
socketio.emit(
"callsign_location", callsign_locations[callsign],
namespace="/sendmsg",
)
def populate_callsign_location(callsign, data=None):
"""Populate the location for the callsign.
if data is passed in, then we have the location already from
an APRS packet. If data is None, then we need to fetch the
location from aprs.fi or REPEAT.
"""
global socketio
"""Fetch the location for the callsign."""
LOG.debug(f"populate_callsign_location {callsign}")
if data:
location_data = _calculate_location_data(data)
callsign_locations[callsign] = location_data
send_location_data_to_browser(location_data)
return
# First we are going to try to get the location from aprs.fi
# if there is no internets, then this will fail and we will
# fallback to calling REPEAT for the location for the callsign.
fallback = False
if not CONF.aprs_fi.apiKey:
LOG.warning(
"Config aprs_fi.apiKey is not set. Can't get location from aprs.fi "
" falling back to sending REPEAT to get location.",
)
fallback = True
else:
try:
aprs_data = plugin_utils.get_aprs_fi(CONF.aprs_fi.apiKey, callsign)
if not len(aprs_data["entries"]):
LOG.error("Didn't get any entries from aprs.fi")
return
lat = float(aprs_data["entries"][0]["lat"])
lon = float(aprs_data["entries"][0]["lng"])
try: # altitude not always provided
alt = float(aprs_data["entries"][0]["altitude"])
except Exception:
alt = 0
location_data = {
'lat': lat,
'long': lon,
'alt': alt,
'lasttime': int(aprs_data["entries"][0]["lasttime"]),
'course': float(aprs_data["entries"][0].get("course", 0)),
'speed': float(aprs_data["entries"][0].get("speed", 0)),
}
location_data = _calculate_location_data(location_data)
callsign_locations[callsign] = location_data
send_location_data_to_browser(location_data)
return
except Exception as ex:
LOG.error(f"Failed to fetch aprs.fi '{ex}'")
fallback = True
if fallback:
# We don't have the location data
# and we can't get it from aprs.fi
# Send a special message to REPEAT to get the location data
LOG.info(f"Sending REPEAT to get location for callsign {callsign}.")
tx.send(
packets.MessagePacket(
from_call=CONF.callsign,
to_call="REPEAT",
message_text=f"ld {callsign}",
),
)
class WebChatProcessPacketThread(rx.APRSDProcessPacketThread):
"""Class that handles packets being sent to us."""
def __init__(self, packet_queue, socketio):
self.socketio = socketio
self.connected = False
@ -132,20 +323,51 @@ class WebChatProcessPacketThread(rx.APRSDProcessPacketThread):
super().process_ack_packet(packet)
ack_num = packet.get("msgNo")
SentMessages().ack(ack_num)
self.socketio.emit(
"ack", SentMessages().get(ack_num),
namespace="/sendmsg",
)
msg = SentMessages().get(ack_num)
if msg:
self.socketio.emit(
"ack", msg,
namespace="/sendmsg",
)
self.got_ack = True
def process_our_message_packet(self, packet: packets.MessagePacket):
global callsign_locations
LOG.info(f"process MessagePacket {repr(packet)}")
# ok lets see if we have the location for the
# person we just sent a message to.
from_call = packet.get("from_call").upper()
if from_call == 'REPEAT':
# We got a message from REPEAT. Is this a location message?
message = packet.get("message_text")
if message.startswith("^ld^"):
location_data = _build_location_from_repeat(message)
callsign = location_data["callsign"]
location_data = _calculate_location_data(location_data)
callsign_locations[callsign] = location_data
send_location_data_to_browser(location_data)
return
elif (from_call not in callsign_locations
and from_call not in callsign_no_track):
# We have to ask aprs for the location for the callsign
# We send a message packet to wb4bor-11 asking for location.
populate_callsign_location(from_call)
# Send the packet to the browser.
self.socketio.emit(
"new", packet.__dict__,
namespace="/sendmsg",
)
class LocationProcessingThread(aprsd_threads.APRSDThread):
"""Class to handle the location processing."""
def __init__(self):
super().__init__("LocationProcessingThread")
def loop(self):
pass
def set_config():
global users

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-globe" viewBox="0 0 16 16">
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m7.5-6.923c-.67.204-1.335.82-1.887 1.855A8 8 0 0 0 5.145 4H7.5zM4.09 4a9.3 9.3 0 0 1 .64-1.539 7 7 0 0 1 .597-.933A7.03 7.03 0 0 0 2.255 4zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a7 7 0 0 0-.656 2.5zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5zM8.5 5v2.5h2.99a12.5 12.5 0 0 0-.337-2.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5zM5.145 12q.208.58.468 1.068c.552 1.035 1.218 1.65 1.887 1.855V12zm.182 2.472a7 7 0 0 1-.597-.933A9.3 9.3 0 0 1 4.09 12H2.255a7 7 0 0 0 3.072 2.472M3.82 11a13.7 13.7 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5zm6.853 3.472A7 7 0 0 0 13.745 12H11.91a9.3 9.3 0 0 1-.64 1.539 7 7 0 0 1-.597.933M8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855q.26-.487.468-1.068zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.7 13.7 0 0 1-.312 2.5m2.802-3.5a7 7 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7 7 0 0 0-3.072-2.472c.218.284.418.598.597.933M10.855 4a8 8 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,5 +1,6 @@
var cleared = false;
var callsign_list = {};
var callsign_location = {};
var message_list = {};
var from_msg_list = {};
var selected_tab_callsign = null;
@ -9,6 +10,25 @@ MSG_TYPE_TX = "tx";
MSG_TYPE_RX = "rx";
MSG_TYPE_ACK = "ack";
function reload_popovers() {
$('[data-bs-toggle="popover"]').popover(
{html: true, animation: true}
);
}
function build_location_string(msg) {
console.log("Building location string");
console.log(msg);
dt = new Date(parseInt(msg['lasttime']) * 1000);
loc = "Last Location Update: " + dt.toLocaleString();
loc += "<br>Latitude: " + msg['lat'] + "<br>Longitude: " + msg['lon'];
loc += "<br>" + "Altitude: " + msg['altitude'] + " m";
loc += "<br>" + "Speed: " + msg['speed'] + " kph";
loc += "<br>" + "Bearing: " + msg['course'] + "°";
loc += "<br>" + "distance: " + msg['distance'] + " km";
return loc;
}
function size_dict(d){c=0; for (i in d) ++c; return c}
function raise_error(msg) {
@ -43,6 +63,8 @@ function init_chat() {
});
socket.on("ack", function(msg) {
console.log("ACK");
console.log(msg);
msg["type"] = MSG_TYPE_ACK;
ack_msg(msg);
});
@ -57,6 +79,20 @@ function init_chat() {
from_msg(msg);
});
socket.on("callsign_location", function(msg) {
console.log("CALLSIGN Location!");
console.log(msg);
callsign_location[msg['callsign']] = msg;
popover_id = callsign_location_popover(msg['callsign'], true);
location_string = build_location_string(msg);
console.log(location_string);
$(popover_id).attr('data-bs-content', location_string);
$(popover_id).removeClass('visually-hidden');
reload_popovers();
save_data();
});
$("#sendform").submit(function(event) {
event.preventDefault();
to_call = $('#to_call').val();
@ -71,7 +107,7 @@ function init_chat() {
return false;
}
msg = {'to': to_call, 'message': message, 'path': path};
console.log(msg);
//console.log(msg);
socket.emit("send", msg);
$('#message').val('');
}
@ -82,6 +118,7 @@ function init_chat() {
init_messages();
}
function tab_string(callsign, id=false) {
name = "msgs"+callsign;
if (id) {
@ -121,6 +158,10 @@ function callsign_tab(callsign) {
return "#"+tab_string(callsign);
}
function callsign_location_popover(callsign, id=false) {
return tab_string(callsign, id)+"Location";
}
function bubble_msg_id(msg, id=false) {
// The id of the div that contains a specific message
name = msg["from_call"] + "_" + msg["msgNo"];
@ -155,20 +196,26 @@ function save_data() {
// Save the relevant data to local storage
localStorage.setItem('callsign_list', JSON.stringify(callsign_list));
localStorage.setItem('message_list', JSON.stringify(message_list));
localStorage.setItem('callsign_location', JSON.stringify(callsign_location));
}
function init_messages() {
// This tries to load any previous conversations from local storage
callsign_list = JSON.parse(localStorage.getItem('callsign_list'));
message_list = JSON.parse(localStorage.getItem('message_list'));
callsign_location = JSON.parse(localStorage.getItem('callsign_location'));
if (callsign_list == null) {
callsign_list = {};
}
if (message_list == null) {
message_list = {};
}
//console.log(callsign_list);
//console.log(message_list);
if (callsign_location == null) {
callsign_location = {};
}
console.log(callsign_list);
console.log(message_list);
console.log(callsign_location);
// Now loop through each callsign and add the tabs
first_callsign = null;
@ -245,19 +292,34 @@ function create_callsign_tab(callsign, active=false) {
tab_id_li = tab_li_string(callsign);
tab_notify_id = tab_notification_id(callsign);
tab_content = tab_content_name(callsign);
popover_id = callsign_location_popover(callsign);
if (active) {
active_str = "active";
} else {
active_str = "";
}
location_str = 'No Location Information';
location_class = 'visually-hidden';
if (callsign in callsign_location) {
location_str = build_location_string(callsign_location[callsign]);
location_class = '';
}
item_html = '<li class="nav-item" role="presentation" callsign="'+callsign+'" id="'+tab_id_li+'">';
//item_html += '<button onClick="callsign_select(\''+callsign+'\');" callsign="'+callsign+'" class="nav-link '+active_str+'" id="'+tab_id+'" data-bs-toggle="tab" data-bs-target="#'+tab_content+'" type="button" role="tab" aria-controls="'+callsign+'" aria-selected="true">';
item_html += '<button onClick="callsign_select(\''+callsign+'\');" callsign="'+callsign+'" class="nav-link position-relative '+active_str+'" id="'+tab_id+'" data-bs-toggle="tab" data-bs-target="#'+tab_content+'" type="button" role="tab" aria-controls="'+callsign+'" aria-selected="true">';
item_html += callsign+'&nbsp;&nbsp;';
item_html += '<img id="'+popover_id+'" src="/static/images/globe.svg" ';
item_html += 'alt="View location information" class="'+location_class+'" ';
item_html += 'data-bs-original-title="APRS Location" data-bs-toggle="popover" data-bs-placement="top" '
item_html += 'data-bs-trigger="hover" data-bs-content="'+location_str+'">&nbsp;';
item_html += '<span id="'+tab_notify_id+'" class="position-absolute top-0 start-80 translate-middle badge bg-danger border border-light rounded-pill visually-hidden">0</span>';
item_html += '<span onclick="delete_tab(\''+callsign+'\');">×</span>';
item_html += '</button></li>'
callsignTabs.append(item_html);
create_callsign_tab_content(callsign, active);
}
@ -288,6 +350,7 @@ function delete_tab(callsign) {
$(tab_content).remove();
delete callsign_list[callsign];
delete message_list[callsign];
delete callsign_location[callsign];
// Now select the first tab
first_tab = $("#msgsTabList").children().first().children().first();
@ -382,11 +445,23 @@ function create_message_html(date, time, from, to, message, ack_id, msg, acked=f
date_str = date + " " + time;
sane_date_str = date_str.replace(/ /g,"").replaceAll("/","").replaceAll(":","");
bubble_msg_class = "bubble-message";
if (ack_id) {
bubble_arrow_class = "bubble-arrow alt";
popover_placement = "left";
} else {
bubble_arrow_class = "bubble-arrow";
popover_placement = "right";
}
msg_html = '<div class="bubble-row'+alt+'">';
msg_html += '<div id="'+bubble_msgid+'" class="'+ bubble_class + '" data-bs-toggle="popover" data-bs-content="'+msg['raw']+'">';
msg_html += '<div id="'+bubble_msgid+'" class="'+ bubble_class + '" ';
msg_html += 'title="APRS Raw Packet" data-bs-placement="'+popover_placement+'" data-bs-toggle="popover" ';
msg_html += 'data-bs-trigger="hover" data-bs-content="'+msg['raw']+'">';
msg_html += '<div class="bubble-text">';
msg_html += '<p class="'+ bubble_name_class +'">'+from+'&nbsp;&nbsp;';
msg_html += '<span class="bubble-timestamp">'+date_str+'</span>';
if (ack_id) {
if (acked) {
msg_html += '<span class="material-symbols-rounded md-10" id="' + ack_id + '">thumb_up</span>';
@ -395,24 +470,11 @@ function create_message_html(date, time, from, to, message, ack_id, msg, acked=f
}
}
msg_html += "</p>";
bubble_msg_class = "bubble-message"
if (ack_id) {
bubble_arrow_class = "bubble-arrow alt"
popover_placement = "left"
} else {
bubble_arrow_class = "bubble-arrow"
popover_placement = "right"
}
msg_html += '<p class="' +bubble_msg_class+ '">'+message+'</p>';
msg_html += '<div class="'+ bubble_arrow_class + '"></div>';
msg_html += "</div></div></div>";
popover_html = '\n<script>$(function () {$(\'[data-bs-toggle="popover"]\').popover('
popover_html += '{title: "APRS Raw Packet", html: false, trigger: \'hover\', placement: \''+popover_placement+'\'});})';
popover_html += '</script>'
return msg_html+popover_html
return msg_html
}
function flash_message(msg) {

View File

@ -64,8 +64,12 @@
to_call.val(callsign);
selected_tab_callsign = callsign;
});
});
/*$('[data-bs-toggle="popover"]').popover(
{html: true, animation: true}
);*/
reload_popovers();
});
</script>
</head>