add decoded path and other ssids

This commit is contained in:
Graham McIntire 2025-07-05 14:02:45 -05:00
parent abdbe399f0
commit f47b3d9f09
No known key found for this signature in database
5 changed files with 459 additions and 27 deletions

View file

@ -21,6 +21,7 @@ defmodule AprsmeWeb.InfoLive.Show do
packet = enrich_packet_with_device_info(packet)
neighbors = get_neighbors(packet, normalized_callsign)
has_weather_packets = PacketUtils.has_weather_packets?(normalized_callsign)
other_ssids = get_other_ssids(normalized_callsign)
socket =
socket
@ -29,6 +30,7 @@ defmodule AprsmeWeb.InfoLive.Show do
|> assign(:neighbors, neighbors)
|> assign(:page_title, "APRS station #{normalized_callsign}")
|> assign(:has_weather_packets, has_weather_packets)
|> assign(:other_ssids, other_ssids)
{:ok, socket}
end
@ -42,12 +44,14 @@ defmodule AprsmeWeb.InfoLive.Show do
packet = enrich_packet_with_device_info(packet)
neighbors = get_neighbors(packet, socket.assigns.callsign)
has_weather_packets = PacketUtils.has_weather_packets?(socket.assigns.callsign)
other_ssids = get_other_ssids(socket.assigns.callsign)
socket =
socket
|> assign(:packet, packet)
|> assign(:neighbors, neighbors)
|> assign(:has_weather_packets, has_weather_packets)
|> assign(:other_ssids, other_ssids)
{:noreply, socket}
else
@ -63,9 +67,9 @@ defmodule AprsmeWeb.InfoLive.Show do
end
defp get_latest_packet(callsign) do
%{callsign: callsign, limit: 1}
|> Packets.get_recent_packets()
|> List.first()
# Get the most recent packet for this callsign, regardless of type
# This ensures we show the most recent activity, not just position packets
Packets.get_latest_packet_for_callsign(callsign)
end
defp enrich_packet_with_device_info(nil), do: nil
@ -145,6 +149,12 @@ defmodule AprsmeWeb.InfoLive.Show do
defp haversine(lat1, lon1, lat2, lon2) do
# Returns distance in km
# Convert Decimal to float if needed
lat1 = to_float(lat1)
lon1 = to_float(lon1)
lat2 = to_float(lat2)
lon2 = to_float(lon2)
r = 6371
dlat = :math.pi() / 180 * (lat2 - lat1)
dlon = :math.pi() / 180 * (lon2 - lon1)
@ -158,6 +168,19 @@ defmodule AprsmeWeb.InfoLive.Show do
r * c
end
defp to_float(%Decimal{} = decimal), do: Decimal.to_float(decimal)
defp to_float(value) when is_float(value), do: value
defp to_float(value) when is_integer(value), do: value * 1.0
defp to_float(value) when is_binary(value) do
case Float.parse(value) do
{f, _} -> f
:error -> 0.0
end
end
defp to_float(_), do: 0.0
defp format_distance(km) when km < 1.0 do
"#{Float.round(km * 1000, 0)} m"
end
@ -168,6 +191,12 @@ defmodule AprsmeWeb.InfoLive.Show do
defp calculate_course(lat1, lon1, lat2, lon2) do
# Calculate bearing from point 1 to point 2
# Convert Decimal to float if needed
lat1 = to_float(lat1)
lon1 = to_float(lon1)
lat2 = to_float(lat2)
lon2 = to_float(lon2)
dlon = :math.pi() / 180 * (lon2 - lon1)
lat1_rad = :math.pi() / 180 * lat1
@ -180,4 +209,278 @@ defmodule AprsmeWeb.InfoLive.Show do
# Convert to 0-360 range
if bearing < 0, do: bearing + 360, else: bearing
end
defp get_other_ssids(callsign) do
import Ecto.Query
alias Aprsme.Packet
alias Aprsme.Repo
# Extract base callsign from the full callsign (remove SSID if present)
base_callsign = extract_base_callsign(callsign)
# Query directly for packets with the same base_callsign
# Get recent packets for the base callsign to find other SSIDs
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
query =
from p in Packet,
where: p.base_callsign == ^base_callsign,
where: p.received_at >= ^one_hour_ago,
order_by: [desc: p.received_at],
limit: 100
query
|> Repo.all()
|> Enum.map(fn p ->
%{
callsign: p.sender,
ssid: p.ssid,
last_heard: PacketUtils.get_timestamp(p),
packet: p
}
end)
|> uniq_by(& &1.callsign)
|> Enum.filter(fn ssid_info -> ssid_info.callsign != callsign end)
|> Enum.sort_by(& &1.last_heard, :desc)
|> Enum.take(10)
end
defp extract_base_callsign(callsign) do
case String.split(callsign, "-") do
[base, _ssid] -> base
[base] -> base
end
end
defp decode_aprs_path(path) when is_binary(path) and path != "" do
path_elements = String.split(path, ",")
decoded_elements = Enum.map(path_elements, &decode_path_element/1)
# Filter out nil results and join with explanations
decoded_elements
|> Enum.reject(&is_nil/1)
|> Enum.join("")
end
defp decode_aprs_path(_), do: nil
defp decode_path_element(element) do
element = String.trim(element)
cond do
# WIDE digipeaters
String.starts_with?(element, "WIDE") ->
case element do
"WIDE1-1" -> "WIDE1-1 (Wide area digipeater, 1 hop)"
"WIDE2-1" -> "WIDE2-1 (Wide area digipeater, 2 hops)"
"WIDE3-1" -> "WIDE3-1 (Wide area digipeater, 3 hops)"
"WIDE4-1" -> "WIDE4-1 (Wide area digipeater, 4 hops)"
"WIDE5-1" -> "WIDE5-1 (Wide area digipeater, 5 hops)"
"WIDE6-1" -> "WIDE6-1 (Wide area digipeater, 6 hops)"
"WIDE7-1" -> "WIDE7-1 (WIDE area digipeater, 7 hops)"
"WIDE1-2" -> "WIDE1-2 (Wide area digipeater, 1 hop, 2nd attempt)"
"WIDE2-2" -> "WIDE2-2 (Wide area digipeater, 2 hops, 2nd attempt)"
_ -> "WIDE digipeater (#{element})"
end
# TRACE digipeaters
String.starts_with?(element, "TRACE") ->
case element do
"TRACE1-1" -> "TRACE1-1 (Trace digipeater, 1 hop)"
"TRACE2-1" -> "TRACE2-1 (Trace digipeater, 2 hops)"
"TRACE3-1" -> "TRACE3-1 (Trace digipeater, 3 hops)"
"TRACE4-1" -> "TRACE4-1 (Trace digipeater, 4 hops)"
"TRACE5-1" -> "TRACE5-1 (Trace digipeater, 5 hops)"
"TRACE6-1" -> "TRACE6-1 (Trace digipeater, 6 hops)"
"TRACE7-1" -> "TRACE7-1 (Trace digipeater, 7 hops)"
_ -> "TRACE digipeater (#{element})"
end
# RELAY digipeaters
String.starts_with?(element, "RELAY") ->
case element do
"RELAY" -> "RELAY (Relay digipeater)"
"RELAY-1" -> "RELAY-1 (Relay digipeater, 1 hop)"
"RELAY-2" -> "RELAY-2 (Relay digipeater, 2 hops)"
_ -> "RELAY digipeater (#{element})"
end
# qAC (APRS-IS connection)
element == "qAC" ->
"qAC (APRS-IS connection)"
# qAO (APRS-IS origin)
element == "qAO" ->
"qAO (APRS-IS origin)"
# qAR (APRS-IS relay)
element == "qAR" ->
"qAR (APRS-IS relay)"
# qAS (APRS-IS server)
element == "qAS" ->
"qAS (APRS-IS server)"
# qAX (APRS-IS client)
element == "qAX" ->
"qAX (APRS-IS client)"
# qAY (APRS-IS gateway)
element == "qAY" ->
"qAY (APRS-IS gateway)"
# qAZ (APRS-IS zone)
element == "qAZ" ->
"qAZ (APRS-IS zone)"
# qBU (APRS-IS user)
element == "qBU" ->
"qBU (APRS-IS user)"
# qBV (APRS-IS vendor)
element == "qBV" ->
"qBV (APRS-IS vendor)"
# qBW (APRS-IS web)
element == "qBW" ->
"qBW (APRS-IS web)"
# qBX (APRS-IS experimental)
element == "qBX" ->
"qBX (APRS-IS experimental)"
# qBY (APRS-IS Y2K)
element == "qBY" ->
"qBY (APRS-IS Y2K)"
# qBZ (APRS-IS Zulu)
element == "qBZ" ->
"qBZ (APRS-IS Zulu)"
# qCA (APRS-IS client application)
element == "qCA" ->
"qCA (APRS-IS client application)"
# qCB (APRS-IS client browser)
element == "qCB" ->
"qCB (APRS-IS client browser)"
# qCC (APRS-IS client console)
element == "qCC" ->
"qCC (APRS-IS client console)"
# qCD (APRS-IS client daemon)
element == "qCD" ->
"qCD (APRS-IS client daemon)"
# qCE (APRS-IS client editor)
element == "qCE" ->
"qCE (APRS-IS client editor)"
# qCF (APRS-IS client filter)
element == "qCF" ->
"qCF (APRS-IS client filter)"
# qCG (APRS-IS client gateway)
element == "qCG" ->
"qCG (APRS-IS client gateway)"
# qCH (APRS-IS client host)
element == "qCH" ->
"qCH (APRS-IS client host)"
# qCI (APRS-IS client interface)
element == "qCI" ->
"qCI (APRS-IS client interface)"
# qCJ (APRS-IS client java)
element == "qCJ" ->
"qCJ (APRS-IS client java)"
# qCK (APRS-IS client kernel)
element == "qCK" ->
"qCK (APRS-IS client kernel)"
# qCL (APRS-IS client library)
element == "qCL" ->
"qCL (APRS-IS client library)"
# qCM (APRS-IS client module)
element == "qCM" ->
"qCM (APRS-IS client module)"
# qCN (APRS-IS client network)
element == "qCN" ->
"qCN (APRS-IS client network)"
# qCO (APRS-IS client object)
element == "qCO" ->
"qCO (APRS-IS client object)"
# qCP (APRS-IS client protocol)
element == "qCP" ->
"qCP (APRS-IS client protocol)"
# qCQ (APRS-IS client query)
element == "qCQ" ->
"qCQ (APRS-IS client query)"
# qCR (APRS-IS client router)
element == "qCR" ->
"qCR (APRS-IS client router)"
# qCS (APRS-IS client server)
element == "qCS" ->
"qCS (APRS-IS client server)"
# qCT (APRS-IS client terminal)
element == "qCT" ->
"qCT (APRS-IS client terminal)"
# qCU (APRS-IS client user)
element == "qCU" ->
"qCU (APRS-IS client user)"
# qCV (APRS-IS client vendor)
element == "qCV" ->
"qCV (APRS-IS client vendor)"
# qCW (APRS-IS client web)
element == "qCW" ->
"qCW (APRS-IS client web)"
# qCX (APRS-IS client experimental)
element == "qCX" ->
"qCX (APRS-IS client experimental)"
# qCY (APRS-IS client Y2K)
element == "qCY" ->
"qCY (APRS-IS client Y2K)"
# qCZ (APRS-IS client Zulu)
element == "qCZ" ->
"qCZ (APRS-IS client Zulu)"
# TCPIP digipeaters
String.starts_with?(element, "TCPIP") ->
case element do
"TCPIP" -> "TCPIP (Internet gateway)"
"TCPIP*" -> "TCPIP* (Internet gateway, no forward)"
_ -> "TCPIP gateway (#{element})"
end
# Generic callsign with SSID (likely a digipeater)
Regex.match?(~r/^[A-Z0-9]+-\d+$/, element) ->
"#{element} (Digipeater)"
# Generic callsign without SSID
Regex.match?(~r/^[A-Z0-9]+$/, element) ->
"#{element} (Station)"
# Default case
true ->
"#{element} (Unknown)"
end
end
end

View file

@ -116,10 +116,32 @@
{@packet.lat || ""}, {@packet.lon || ""}
</dd>
</div>
<%= if @packet.lat && @packet.lon do %>
<div>
<dt class="text-xs font-medium opacity-70">Grid Square</dt>
<dd class="mt-1 text-sm font-semibold">
<%= case Gridsquare.encode(@packet.lon, @packet.lat) do %>
<% %Gridsquare.EncodeResult{grid_reference: grid_ref} -> %>
{grid_ref}
<% _ -> %>
-
<% end %>
</dd>
</div>
<% end %>
<div>
<dt class="text-xs font-medium opacity-70">Last Position</dt>
<dd class="mt-1 text-sm font-semibold">
{AprsmeWeb.MapLive.PacketUtils.get_timestamp(@packet)}
<dd class="mt-1 text-sm">
<%= if @packet.received_at do %>
<div class="font-semibold">
{AprsmeWeb.TimeHelpers.time_ago_in_words(@packet.received_at)}
</div>
<div class="text-xs opacity-70 font-mono">
{Calendar.strftime(@packet.received_at, "%Y-%m-%d %H:%M:%S UTC")}
</div>
<% else %>
<span class="opacity-70">Unknown</span>
<% end %>
</dd>
</div>
<%= if @packet.altitude do %>
@ -193,6 +215,14 @@
<dt class="text-xs font-medium opacity-70">Path</dt>
<dd class="mt-1 text-sm font-semibold">{@packet.path || ""}</dd>
</div>
<%= if @packet.path && @packet.path != "" do %>
<div class="col-span-2">
<dt class="text-xs font-medium opacity-70">Decoded Path</dt>
<dd class="mt-1 text-sm">
{decode_aprs_path(@packet.path)}
</dd>
</div>
<% end %>
<div class="col-span-2">
<dt class="text-xs font-medium opacity-70">Raw Packet</dt>
<dd class="mt-1 text-xs font-mono break-all">
@ -208,6 +238,125 @@
</div>
</div>
<!-- Other SSIDs -->
<%= if length(@other_ssids) > 0 do %>
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<div class="flex items-center mb-3">
<div class="flex-shrink-0">
<svg
class="h-4 w-4 opacity-70"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
</div>
<div class="ml-2">
<h3 class="text-sm font-medium">Other SSIDs</h3>
</div>
</div>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th class="text-xs font-medium uppercase tracking-wider">
Callsign
</th>
<th class="text-xs font-medium uppercase tracking-wider">
Distance & Direction
</th>
<th class="text-xs font-medium uppercase tracking-wider">
Last Heard
</th>
</tr>
</thead>
<tbody>
<%= for ssid_info <- @other_ssids do %>
<tr>
<td class="font-medium">
<div class="flex items-center space-x-2">
<.link
navigate={~p"/info/#{ssid_info.callsign}"}
class="link link-primary"
>
{ssid_info.callsign}
</.link>
<%= if ssid_info.packet do %>
<% {symbol_table_id, symbol_code} =
AprsmeWeb.MapLive.PacketUtils.get_symbol_info(ssid_info.packet) %>
<% symbol_table =
if symbol_table_id in ["/", "\\", "]"],
do: symbol_table_id,
else: "/" %>
<% symbol_code = symbol_code || ">" %>
<% table_id =
if symbol_table == "/",
do: "0",
else: if(symbol_table == "]", do: "2", else: "1") %>
<% symbol_code_ord =
symbol_code
|> String.to_charlist()
|> List.first()
|> (fn c -> if is_integer(c), do: c, else: 63 end).() %>
<% index = symbol_code_ord - 33 %>
<% safe_index = max(0, min(index, 93)) %>
<% column = rem(safe_index, 16) %>
<% row = div(safe_index, 16) %>
<% x = -column * 128 %>
<% y = -row * 128 %>
<% symbol_img = "/aprs-symbols/aprs-symbols-128-#{table_id}@2x.png" %>
<div
style={"width: 32px; height: 32px; background-image: url(#{symbol_img}); background-position: #{x / 4}px #{y / 4}px; background-size: 512px 192px; background-repeat: no-repeat; image-rendering: pixelated; opacity: 1.0; display: inline-block; vertical-align: middle; margin-bottom: -6px;"}
title={"Symbol: #{symbol_code} (#{symbol_code_ord}) at row #{row}, col #{column}"}
>
</div>
<% end %>
</div>
</td>
<td class="opacity-70">
<%= if ssid_info.packet && ssid_info.packet.lat && ssid_info.packet.lon && @packet && @packet.lat && @packet.lon do %>
<% dist =
haversine(
@packet.lat,
@packet.lon,
ssid_info.packet.lat,
ssid_info.packet.lon
) %>
<% course =
calculate_course(
@packet.lat,
@packet.lon,
ssid_info.packet.lat,
ssid_info.packet.lon
) %>
<%= if course do %>
{format_distance(dist)} @ {Float.round(course, 0)}°
<% else %>
{format_distance(dist)}
<% end %>
<% else %>
-
<% end %>
</td>
<td class="opacity-70">
{ssid_info.last_heard || ""}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
<% end %>
<!-- Neighboring stations -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">

View file

@ -208,28 +208,6 @@ defmodule AprsmeWeb.StatusLive.Index do
</div>
</div>
</div>
<!-- Application Information -->
<div class="mb-8">
<h2 class="text-xl font-semibold mb-4">Application Information</h2>
<div class="card bg-base-200">
<div class="card-body">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex items-center">
<span class="text-sm font-medium opacity-70 mr-2">Version:</span>
<span class="text-sm font-mono">{@version}</span>
</div>
<div class="flex items-center">
<span class="text-sm font-medium opacity-70 mr-2">Current Time:</span>
<span class="text-sm font-mono">
{Calendar.strftime(@current_time, "%Y-%m-%d %H:%M:%S UTC")}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -82,6 +82,7 @@ defmodule Aprsme.MixProject do
github: "tailwindlabs/heroicons", tag: "v2.1.1", sparse: "optimized", app: false, compile: false, depth: 1},
{:bandit, "~> 1.5"},
{:req, "~> 0.5"},
{:gridsquare, "~> 0.2.0"},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.0", only: :dev, runtime: false},
{:exvcr, "~> 0.15", only: :test},

View file

@ -26,6 +26,7 @@
"geo_postgis": {:hex, :geo_postgis, "3.7.1", "614f25b42334a615bd54bb09c22030b1aac7bac8f829bd823ab1faccf093a324", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:geo, "~> 3.6 or ~> 4.0", [hex: :geo, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "c20d823c600d35b7fe9ddd5be03052bb7136c57d6f1775dbd46871545e405280"},
"geocalc": {:hex, :geocalc, "0.8.5", "b9886679e44c323e5b72dcd90a64f834d775d2600af0b656ea9f07ccdacaa5a6", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "3870c25c78513ec0456b69324c2be1af2202961002e81fb659559e3db162c802"},
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
"gridsquare": {:hex, :gridsquare, "0.2.0", "c556a5b5101db89743264b0d728601023035863487bbe36e9e50e93839adb4d7", [:mix], [], "hexpm", "dbf0dbb484681a1e97819b0667bfeaa8c8a48cf06a54bf96a8f26ee4158ad31a"},
"hackney": {:hex, :hackney, "1.24.1", "f5205a125bba6ed4587f9db3cc7c729d11316fa8f215d3e57ed1c067a9703fa9", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "f4a7392a0b53d8bbc3eb855bdcc919cd677358e65b2afd3840b5b3690c4c8a39"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},