From f47b3d9f096821f4bee36e4c29010a57fa490e1b Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 5 Jul 2025 14:02:45 -0500 Subject: [PATCH] add decoded path and other ssids --- lib/aprsme_web/live/info_live/show.ex | 309 ++++++++++++++++++- lib/aprsme_web/live/info_live/show.html.heex | 153 ++++++++- lib/aprsme_web/live/status_live/index.ex | 22 -- mix.exs | 1 + mix.lock | 1 + 5 files changed, 459 insertions(+), 27 deletions(-) diff --git a/lib/aprsme_web/live/info_live/show.ex b/lib/aprsme_web/live/info_live/show.ex index 349a170..a0851a2 100644 --- a/lib/aprsme_web/live/info_live/show.ex +++ b/lib/aprsme_web/live/info_live/show.ex @@ -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 diff --git a/lib/aprsme_web/live/info_live/show.html.heex b/lib/aprsme_web/live/info_live/show.html.heex index ba7986e..6f170b5 100644 --- a/lib/aprsme_web/live/info_live/show.html.heex +++ b/lib/aprsme_web/live/info_live/show.html.heex @@ -116,10 +116,32 @@ {@packet.lat || ""}, {@packet.lon || ""} + <%= if @packet.lat && @packet.lon do %> +
+
Grid Square
+
+ <%= case Gridsquare.encode(@packet.lon, @packet.lat) do %> + <% %Gridsquare.EncodeResult{grid_reference: grid_ref} -> %> + {grid_ref} + <% _ -> %> + - + <% end %> +
+
+ <% end %>
Last Position
-
- {AprsmeWeb.MapLive.PacketUtils.get_timestamp(@packet)} +
+ <%= if @packet.received_at do %> +
+ {AprsmeWeb.TimeHelpers.time_ago_in_words(@packet.received_at)} +
+
+ {Calendar.strftime(@packet.received_at, "%Y-%m-%d %H:%M:%S UTC")} +
+ <% else %> + Unknown + <% end %>
<%= if @packet.altitude do %> @@ -193,6 +215,14 @@
Path
{@packet.path || ""}
+ <%= if @packet.path && @packet.path != "" do %> +
+
Decoded Path
+
+ {decode_aprs_path(@packet.path)} +
+
+ <% end %>
Raw Packet
@@ -208,6 +238,125 @@
+ + <%= if length(@other_ssids) > 0 do %> +
+
+
+
+ + + +
+
+

Other SSIDs

+
+
+
+ + + + + + + + + + <%= for ssid_info <- @other_ssids do %> + + + + + + <% end %> + +
+ Callsign + + Distance & Direction + + Last Heard +
+
+ <.link + navigate={~p"/info/#{ssid_info.callsign}"} + class="link link-primary" + > + {ssid_info.callsign} + + <%= 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" %> +
+
+ <% end %> +
+
+ <%= 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 %> + + {ssid_info.last_heard || ""} +
+
+
+
+ <% end %> +
diff --git a/lib/aprsme_web/live/status_live/index.ex b/lib/aprsme_web/live/status_live/index.ex index 2fc337a..6e064a3 100644 --- a/lib/aprsme_web/live/status_live/index.ex +++ b/lib/aprsme_web/live/status_live/index.ex @@ -208,28 +208,6 @@ defmodule AprsmeWeb.StatusLive.Index do
- - -
-

Application Information

-
-
-
-
- Version: - {@version} -
- -
- Current Time: - - {Calendar.strftime(@current_time, "%Y-%m-%d %H:%M:%S UTC")} - -
-
-
-
-
diff --git a/mix.exs b/mix.exs index faeab14..b518fec 100644 --- a/mix.exs +++ b/mix.exs @@ -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}, diff --git a/mix.lock b/mix.lock index 0eafa79..70a48ef 100644 --- a/mix.lock +++ b/mix.lock @@ -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"},