aprs.me/lib/aprsme_web/live/info_live/show.ex

179 lines
4.9 KiB
Elixir

defmodule AprsmeWeb.InfoLive.Show do
@moduledoc false
use AprsmeWeb, :live_view
alias Aprsme.Packets
alias AprsmeWeb.MapLive.PacketUtils
@neighbor_radius_km 10
@neighbor_limit 10
@impl true
def mount(%{"callsign" => callsign}, _session, socket) do
normalized_callsign = String.upcase(String.trim(callsign))
# Subscribe to Postgres notifications for live updates
if connected?(socket) do
Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets")
end
packet = get_latest_packet(normalized_callsign)
packet = enrich_packet_with_device_info(packet)
neighbors = get_neighbors(packet, normalized_callsign)
socket =
socket
|> assign(:callsign, normalized_callsign)
|> assign(:packet, packet)
|> assign(:neighbors, neighbors)
|> assign(:page_title, "APRS station #{normalized_callsign}")
{:ok, socket}
end
@impl true
def handle_info({:postgres_packet, packet}, socket) do
# Only update if the packet is for our callsign
if packet_matches_callsign?(packet, socket.assigns.callsign) do
# Refresh data when new packet arrives
packet = get_latest_packet(socket.assigns.callsign)
packet = enrich_packet_with_device_info(packet)
neighbors = get_neighbors(packet, socket.assigns.callsign)
socket =
socket
|> assign(:packet, packet)
|> assign(:neighbors, neighbors)
{:noreply, socket}
else
{:noreply, socket}
end
end
def handle_info(_message, socket), do: {:noreply, socket}
defp packet_matches_callsign?(packet, callsign) do
packet_sender = Map.get(packet, "sender") || Map.get(packet, :sender, "")
String.upcase(packet_sender) == String.upcase(callsign)
end
defp get_latest_packet(callsign) do
%{callsign: callsign, limit: 1}
|> Packets.get_recent_packets()
|> List.first()
end
defp enrich_packet_with_device_info(nil), do: nil
defp enrich_packet_with_device_info(packet) do
device_identifier = Map.get(packet, :device_identifier) || Map.get(packet, "device_identifier")
device =
if is_binary(device_identifier), do: Aprsme.DeviceIdentification.lookup_device_by_identifier(device_identifier)
model = if device, do: device.model
vendor = if device, do: device.vendor
contact = if device, do: device.contact
class = if device, do: device.class
packet
|> Map.put(:device_model, model)
|> Map.put(:device_vendor, vendor)
|> Map.put(:device_contact, contact)
|> Map.put(:device_class, class)
end
defp get_neighbors(nil, _callsign), do: []
defp get_neighbors(packet, callsign) do
lat = packet.lat
lon = packet.lon
if is_nil(lat) or is_nil(lon) do
[]
else
# Simple bounding box for ~10km radius
delta = @neighbor_radius_km / 111.0
min_lat = lat - delta
max_lat = lat + delta
min_lon = lon - delta
max_lon = lon + delta
opts = %{bounds: [min_lon, min_lat, max_lon, max_lat], limit: 50}
opts
|> Packets.get_recent_packets()
|> Enum.filter(fn p ->
(p.sender != callsign and p.lat) && p.lon
end)
|> uniq_by(& &1.sender)
|> Enum.map(fn p ->
dist = haversine(lat, lon, p.lat, p.lon)
course = calculate_course(lat, lon, p.lat, p.lon)
%{
callsign: p.sender,
distance: format_distance(dist),
course: course,
last_heard: PacketUtils.get_timestamp(p),
packet: p
}
end)
|> Enum.sort_by(& &1.distance)
|> Enum.take(@neighbor_limit)
end
end
defp uniq_by(list, fun) do
list
|> Enum.reduce({MapSet.new(), []}, fn item, {set, acc} ->
key = fun.(item)
if MapSet.member?(set, key) do
{set, acc}
else
{MapSet.put(set, key), [item | acc]}
end
end)
|> elem(1)
|> Enum.reverse()
end
defp haversine(lat1, lon1, lat2, lon2) do
# Returns distance in km
r = 6371
dlat = :math.pi() / 180 * (lat2 - lat1)
dlon = :math.pi() / 180 * (lon2 - lon1)
a =
:math.sin(dlat / 2) * :math.sin(dlat / 2) +
:math.cos(:math.pi() / 180 * lat1) * :math.cos(:math.pi() / 180 * lat2) *
:math.sin(dlon / 2) * :math.sin(dlon / 2)
c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a))
r * c
end
defp format_distance(km) when km < 1.0 do
"#{Float.round(km * 1000, 0)} m"
end
defp format_distance(km) do
"#{Float.round(km, 2)} km"
end
defp calculate_course(lat1, lon1, lat2, lon2) do
# Calculate bearing from point 1 to point 2
dlon = :math.pi() / 180 * (lon2 - lon1)
lat1_rad = :math.pi() / 180 * lat1
lat2_rad = :math.pi() / 180 * lat2
y = :math.sin(dlon) * :math.cos(lat2_rad)
x = :math.cos(lat1_rad) * :math.sin(lat2_rad) - :math.sin(lat1_rad) * :math.cos(lat2_rad) * :math.cos(dlon)
bearing = :math.atan2(y, x) * 180 / :math.pi()
# Convert to 0-360 range
if bearing < 0, do: bearing + 360, else: bearing
end
end