defmodule AprsmeWeb.InfoLive.Show do @moduledoc false use AprsmeWeb, :live_view use Gettext, backend: AprsmeWeb.Gettext import AprsmeWeb.Components.InfoMapComponent import Phoenix.HTML, only: [raw: 1] alias Aprsme.Callsign alias Aprsme.EncodingUtils alias Aprsme.Packets alias AprsmeWeb.AprsSymbol alias AprsmeWeb.Live.SharedPacketHandler alias AprsmeWeb.MapLive.PacketUtils alias AprsmeWeb.TimeUtils @neighbor_limit 10 @impl true def mount(%{"callsign" => callsign}, _session, socket) do normalized_callsign = Callsign.normalize(callsign) # Subscribe to callsign-specific topic for live updates if connected?(socket) do Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets:#{normalized_callsign}") end packet = get_latest_packet(normalized_callsign) packet = if packet, do: SharedPacketHandler.enrich_with_device_info(packet) # Get locale from socket assigns (set by LocaleHook) locale = Map.get(socket.assigns, :locale, "en") neighbors = get_neighbors(packet, normalized_callsign, locale) has_weather_packets = PacketUtils.has_weather_packets?(normalized_callsign) other_ssids = get_other_ssids(normalized_callsign) heard_by_stations = get_heard_by_stations(normalized_callsign, locale) stations_heard_by = get_stations_heard_by(normalized_callsign, locale) socket = socket |> assign(:callsign, normalized_callsign) |> assign(:packet, packet) |> assign(:neighbors, neighbors) |> assign(:page_title, "APRS station #{normalized_callsign}") |> assign(:has_weather_packets, has_weather_packets) |> assign(:other_ssids, other_ssids) |> assign(:heard_by_stations, heard_by_stations) |> assign(:stations_heard_by, stations_heard_by) {:ok, socket} end @impl true def handle_info({:postgres_packet, packet}, socket) do # Since we're subscribed to callsign-specific topic, no need to filter # The packet from PostgreSQL notify already contains all fields process_packet_update(packet, socket) end def handle_info(_message, socket), do: {:noreply, socket} defp process_packet_update(incoming_packet, socket) do # Log for debugging require Logger Logger.debug( "InfoLive received packet update for #{socket.assigns.callsign}: #{inspect(Map.get(incoming_packet, "raw_packet"))}" ) # Get the new packet data new_packet = get_latest_packet(socket.assigns.callsign) new_packet = if new_packet, do: SharedPacketHandler.enrich_with_device_info(new_packet) current_packet = socket.assigns.packet # Check if this is a position update by comparing location and other key fields position_changed = position_changed?(current_packet, new_packet) if position_changed do # Only update position-related data if position changed # Get locale from socket assigns locale = Map.get(socket.assigns, :locale, "en") neighbors = get_neighbors(new_packet, socket.assigns.callsign, locale) socket = socket |> assign(:packet, new_packet) |> assign(:neighbors, neighbors) {:noreply, socket} else # Just update the packet data (for timestamp, comment, etc.) without affecting neighbors socket = assign(socket, :packet, new_packet) {:noreply, socket} end end defp position_changed?(nil, _new_packet), do: true defp position_changed?(_current_packet, nil), do: false defp position_changed?(current_packet, new_packet) do # Compare lat/lon to see if position actually changed current_lat = to_float_safe(current_packet.lat) current_lon = to_float_safe(current_packet.lon) new_lat = to_float_safe(new_packet.lat) new_lon = to_float_safe(new_packet.lon) # If any coordinate is invalid, consider it a change case {current_lat, current_lon, new_lat, new_lon} do {nil, _, _, _} -> true {_, nil, _, _} -> true {_, _, nil, _} -> true {_, _, _, nil} -> true {curr_lat, curr_lon, new_lat, new_lon} -> # Consider position changed if coordinates differ by more than the configured threshold threshold = Application.get_env(:aprsme, :position_tracking, [])[:change_threshold] || 0.001 lat_diff = abs(curr_lat - new_lat) lon_diff = abs(curr_lon - new_lon) lat_diff > threshold or lon_diff > threshold end end # Expose for testing if Mix.env() == :test do def position_changed_for_test(current, new), do: position_changed?(current, new) end defp get_latest_packet(callsign) do # Get the most recent packet for this callsign, regardless of type # This ensures we show the most recent activity, not just position packets packet = Packets.get_latest_packet_for_callsign(callsign) # If this packet doesn't have position data, try to get the latest position packet if packet && (is_nil(packet.lat) || is_nil(packet.lon)) do position_packet = get_latest_position_packet(callsign) if position_packet do # Merge position data from position packet into the latest packet %{packet | lat: position_packet.lat, lon: position_packet.lon, location: position_packet.location} else packet end else packet end end defp get_latest_position_packet(callsign) do import Ecto.Query # Get the most recent packet with valid position data alias Aprsme.Repo Repo.one( from(p in Aprsme.Packet, where: fragment("upper(?)", p.sender) == ^String.upcase(String.trim(callsign)), where: not is_nil(p.lat) and not is_nil(p.lon), order_by: [desc: p.received_at], limit: 1, select: %{p | lat: fragment("ST_Y(?)", p.location), lon: fragment("ST_X(?)", p.location)} ) ) end defp get_neighbors(nil, _callsign, _locale), do: [] defp get_neighbors(packet, callsign, locale) do lat = packet.lat lon = packet.lon if is_nil(lat) or is_nil(lon) do [] else # Convert Decimal to float if needed lat_float = to_float(lat) lon_float = to_float(lon) # Use the spatial query to get the closest stations # The query already returns them sorted by distance nearby_packets = Packets.get_nearby_stations( lat_float, lon_float, callsign, %{limit: @neighbor_limit} ) # Calculate distance and course for each neighbor # The packets are already sorted by distance from the database Enum.map(nearby_packets, 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, locale), distance_km: dist, course: course, last_heard: format_timestamp_for_display(p), packet: p } end) end end def 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) 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 # Safe float conversion that preserves nil for invalid coordinates defp to_float_safe(value), do: EncodingUtils.to_float(value) # Legacy float conversion that defaults to 0.0 for backward compatibility defp to_float(value), do: EncodingUtils.to_float(value) || 0.0 defp format_timestamp_for_display(packet) do received_at = get_received_at(packet) if received_at do timestamp_dt = AprsmeWeb.TimeHelpers.to_datetime(received_at) if timestamp_dt do %{ time_ago: AprsmeWeb.TimeHelpers.time_ago_in_words(timestamp_dt), formatted: Calendar.strftime(timestamp_dt, "%Y-%m-%d %H:%M:%S UTC"), timestamp: DateTime.to_iso8601(timestamp_dt) } else %{ time_ago: gettext("Unknown"), formatted: "", timestamp: nil } end else %{ time_ago: gettext("Unknown"), formatted: "", timestamp: nil } end end defp get_received_at(packet) do cond do Map.has_key?(packet, :received_at) -> packet.received_at Map.has_key?(packet, "received_at") -> packet["received_at"] true -> nil end end def format_distance(km, locale \\ "en") do case locale do "en" -> # Use imperial units for English locale miles = Aprsme.Convert.kph_to_mph(km) if miles < 1.0 do feet = miles * 5280 "#{Float.round(feet, 0)} ft" else "#{Float.round(miles, 2)} mi" end _ -> # Use metric units for other locales if km < 1.0 do "#{Float.round(km * 1000, 0)} m" else "#{Float.round(km, 2)} km" end end end def 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 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 defp get_other_ssids(callsign) do alias Aprsme.Packet alias Aprsme.Repo # Extract base callsign from the full callsign (remove SSID if present) base_callsign = extract_base_callsign(callsign) # Get recent packets for the base callsign to find other SSIDs one_hour_ago = TimeUtils.one_hour_ago() # Use a window function to get the most recent packet per sender # This is much more efficient than fetching 100 packets and filtering in Elixir query = """ WITH recent_ssids AS ( SELECT DISTINCT ON (sender) sender, ssid, received_at, id, symbol_table_id, symbol_code FROM packets WHERE base_callsign = $1 AND received_at >= $2 AND sender != $3 ORDER BY sender, received_at DESC ) SELECT * FROM recent_ssids ORDER BY received_at DESC LIMIT 10 """ case Repo.query(query, [base_callsign, one_hour_ago, callsign]) do {:ok, result} -> Enum.map(result.rows, fn [sender, ssid, received_at, id, symbol_table_id, symbol_code] -> # Create a minimal packet struct for display packet = %Packet{ id: id, sender: sender, ssid: ssid, received_at: received_at, symbol_table_id: symbol_table_id, symbol_code: symbol_code } %{ callsign: sender, ssid: ssid, last_heard: format_timestamp_for_display(packet), packet: packet } end) {:error, _} -> [] end end defp extract_base_callsign(callsign) do Callsign.extract_base(callsign) end defp get_heard_by_stations(callsign, locale) do alias Aprsme.Repo # Get packets from the last month where this callsign was heard on RF one_month_ago = DateTime.add(DateTime.utc_now(), -30, :day) # Query to find stations that heard this callsign directly # In APRS, the first station with an asterisk (*) in the path is the one that heard the packet directly query = """ WITH parsed_paths AS ( SELECT id, sender, path, received_at, lat, lon, location, -- Extract the first digipeater from the path CASE WHEN path ~ '[A-Z0-9]+-?[0-9]*\\*' THEN substring(path from '([A-Z0-9]+-?[0-9]*)\\*') ELSE NULL END as first_digipeater FROM packets WHERE sender = $1 AND received_at >= $2 AND path IS NOT NULL AND path != '' AND path !~ '^TCPIP' AND path !~ ',TCPIP' ), digipeater_stats AS ( SELECT first_digipeater as digipeater, MIN(received_at) as first_heard, MAX(received_at) as last_heard, COUNT(*) as packet_count, -- Find the packet with maximum distance for this digipeater (SELECT pp2.id FROM parsed_paths pp2 WHERE pp2.first_digipeater = pp.first_digipeater AND pp2.lat IS NOT NULL AND pp2.lon IS NOT NULL ORDER BY ST_Distance( pp2.location::geography, (SELECT location::geography FROM packets WHERE sender = pp2.first_digipeater AND location IS NOT NULL ORDER BY received_at DESC LIMIT 1) ) DESC NULLS LAST LIMIT 1 ) as longest_path_packet_id FROM parsed_paths pp WHERE first_digipeater IS NOT NULL GROUP BY first_digipeater ) SELECT ds.digipeater, ds.first_heard, ds.last_heard, ds.packet_count, p.received_at as longest_path_time, CASE WHEN p.location IS NOT NULL AND dig_loc.location IS NOT NULL THEN ST_Distance(p.location::geography, dig_loc.location::geography) / 1000.0 ELSE NULL END as longest_distance_km FROM digipeater_stats ds LEFT JOIN packets p ON p.id = ds.longest_path_packet_id LEFT JOIN LATERAL ( SELECT location FROM packets WHERE sender = ds.digipeater AND location IS NOT NULL ORDER BY received_at DESC LIMIT 1 ) dig_loc ON true ORDER BY ds.last_heard DESC LIMIT 50 """ case Repo.query(query, [callsign, one_month_ago]) do {:ok, result} -> result.rows |> Enum.map(fn row -> case row do [digipeater, first_heard, last_heard, packet_count, longest_path_time, longest_distance_km] -> %{ digipeater: digipeater || "", first_heard: first_heard, last_heard: last_heard, packet_count: packet_count || 0, longest_distance_km: longest_distance_km, longest_distance: if(longest_distance_km, do: format_distance(longest_distance_km, locale)), longest_path_time: longest_path_time } _ -> nil end end) |> Enum.reject(&is_nil/1) {:error, error} -> require Logger Logger.error("Error in get_heard_by_stations: #{inspect(error)}") [] end end defp get_stations_heard_by(callsign, locale) do alias Aprsme.Repo # Get packets from the last month where this callsign heard other stations on RF one_month_ago = DateTime.add(DateTime.utc_now(), -30, :day) # Optimized query to find stations that were heard directly by this callsign # Eliminates correlated subqueries and reduces redundant operations query = """ WITH digipeater_location AS ( -- Get the most recent location for the digipeater (cached once) SELECT location::geography as dig_loc FROM packets WHERE sender = $1 AND location IS NOT NULL ORDER BY received_at DESC LIMIT 1 ), parsed_paths AS ( SELECT id, sender, path, received_at, lat, lon, location, -- Extract the first digipeater from the path more efficiently CASE WHEN path ~ ($1 || '\\*') THEN substring(path from ($1 || '\\*')) ELSE NULL END as first_digipeater FROM packets WHERE path ~ ($1 || '\\*') AND received_at >= $2 AND path IS NOT NULL AND path != '' AND path !~ '^TCPIP' AND path !~ ',TCPIP' AND lat IS NOT NULL AND lon IS NOT NULL ), station_stats AS ( SELECT sender as station, MIN(received_at) as first_heard, MAX(received_at) as last_heard, COUNT(*) as packet_count FROM parsed_paths WHERE first_digipeater = $1 GROUP BY sender ), longest_paths AS ( -- Find the longest path packet for each station in a single pass SELECT DISTINCT ON (pp.sender) pp.sender, pp.id as longest_path_packet_id, pp.received_at as longest_path_time, CASE WHEN pp.location IS NOT NULL AND dl.dig_loc IS NOT NULL THEN ST_Distance(pp.location::geography, dl.dig_loc) / 1000.0 ELSE NULL END as longest_distance_km FROM parsed_paths pp CROSS JOIN digipeater_location dl WHERE pp.first_digipeater = $1 ORDER BY pp.sender, ST_Distance(pp.location::geography, dl.dig_loc) DESC NULLS LAST, pp.received_at DESC ) SELECT ss.station, ss.first_heard, ss.last_heard, ss.packet_count, lp.longest_path_time, lp.longest_distance_km FROM station_stats ss LEFT JOIN longest_paths lp ON lp.sender = ss.station ORDER BY ss.last_heard DESC LIMIT 50 """ case Repo.query(query, [callsign, one_month_ago]) do {:ok, result} -> result.rows |> Enum.map(fn row -> case row do [station, first_heard, last_heard, packet_count, longest_path_time, longest_distance_km] -> %{ station: station || "", first_heard: first_heard, last_heard: last_heard, packet_count: packet_count || 0, longest_distance_km: longest_distance_km, longest_distance: if(longest_distance_km, do: format_distance(longest_distance_km, locale)), longest_path_time: longest_path_time } _ -> nil end end) |> Enum.reject(&is_nil/1) {:error, error} -> require Logger Logger.error("Error in get_stations_heard_by: #{inspect(error)}") [] end end @doc """ Renders an APRS symbol style for use in templates. """ def render_symbol_style(packet, size \\ 32) do if packet do {symbol_table_id, symbol_code} = AprsSymbol.extract_from_packet(packet) AprsSymbol.render_style(symbol_table_id, symbol_code, size) else # Return empty style if no packet "" end end @doc """ Renders an APRS symbol as HTML for overlay symbols that need proper overlay character display. """ def render_symbol_html(packet, size \\ 32) do if packet do {symbol_table_id, symbol_code} = AprsSymbol.extract_from_packet(packet) # Check if this is an overlay symbol if symbol_table_id && String.match?(symbol_table_id, ~r/^[A-Z0-9]$/) do # Use layered sprite backgrounds for overlay symbols sprite_info = AprsSymbol.get_sprite_info(symbol_table_id, symbol_code) overlay_sprite_info = AprsSymbol.get_overlay_character_sprite_info(symbol_table_id) raw("""
""") else # Use style rendering for non-overlay symbols raw("""
""") end else # Return empty if no packet raw("") 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 @doc """ Parses the APRS path and creates linked callsigns where appropriate. Returns a list of tuples: {:text, string} or {:link, callsign, display_text} """ def parse_path_with_links(path) when is_binary(path) and path != "" do path |> String.split(",") |> Enum.map(&parse_path_element_with_link/1) end def parse_path_with_links(_), do: [] defp parse_path_element_with_link(element) do element = String.trim(element) # Check if it's a callsign (with or without SSID) if Regex.match?(~r/^[A-Z0-9]{1,6}(-\d{1,2})?(\*)?$/, element) and not String.starts_with?(element, "WIDE") and not String.starts_with?(element, "TRACE") and not String.starts_with?(element, "RELAY") and not String.starts_with?(element, "TCPIP") and not String.starts_with?(element, "q") do # Remove asterisk if present (indicates the packet was digipeated through this station) callsign = String.replace(element, "*", "") display = if String.ends_with?(element, "*"), do: "#{callsign}*", else: callsign {:link, callsign, display} else # Not a linkable callsign, just return as text {:text, element, element} end end defp decode_path_element(element) do element = String.trim(element) cond do # WIDE digipeaters String.starts_with?(element, "WIDE") -> case element do "WIDE1-1" -> gettext("WIDE1-1 (Wide area digipeater, 1 hop)") "WIDE2-1" -> gettext("WIDE2-1 (Wide area digipeater, 2 hops)") "WIDE3-1" -> gettext("WIDE3-1 (Wide area digipeater, 3 hops)") "WIDE4-1" -> gettext("WIDE4-1 (Wide area digipeater, 4 hops)") "WIDE5-1" -> gettext("WIDE5-1 (Wide area digipeater, 5 hops)") "WIDE6-1" -> gettext("WIDE6-1 (Wide area digipeater, 6 hops)") "WIDE7-1" -> gettext("WIDE7-1 (WIDE area digipeater, 7 hops)") "WIDE1-2" -> gettext("WIDE1-2 (Wide area digipeater, 1 hop, 2nd attempt)") "WIDE2-2" -> gettext("WIDE2-2 (Wide area digipeater, 2 hops, 2nd attempt)") _ -> gettext("WIDE digipeater (%{element})", element: element) end # TRACE digipeaters String.starts_with?(element, "TRACE") -> case element do "TRACE1-1" -> gettext("TRACE1-1 (Trace digipeater, 1 hop)") "TRACE2-1" -> gettext("TRACE2-1 (Trace digipeater, 2 hops)") "TRACE3-1" -> gettext("TRACE3-1 (Trace digipeater, 3 hops)") "TRACE4-1" -> gettext("TRACE4-1 (Trace digipeater, 4 hops)") "TRACE5-1" -> gettext("TRACE5-1 (Trace digipeater, 5 hops)") "TRACE6-1" -> gettext("TRACE6-1 (Trace digipeater, 6 hops)") "TRACE7-1" -> gettext("TRACE7-1 (Trace digipeater, 7 hops)") _ -> gettext("TRACE digipeater (%{element})", element: element) end # RELAY digipeaters String.starts_with?(element, "RELAY") -> case element do "RELAY" -> gettext("RELAY (Relay digipeater)") "RELAY-1" -> gettext("RELAY-1 (Relay digipeater, 1 hop)") "RELAY-2" -> gettext("RELAY-2 (Relay digipeater, 2 hops)") _ -> gettext("RELAY digipeater (%{element})", element: 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" -> gettext("TCPIP (Internet gateway)") "TCPIP*" -> gettext("TCPIP* (Internet gateway, no forward)") _ -> gettext("TCPIP gateway (%{element})", element: element) end # Generic callsign with SSID (likely a digipeater) Regex.match?(~r/^[A-Z0-9]+-\d+$/, element) -> gettext("%{element} (Digipeater)", element: element) # Generic callsign without SSID Regex.match?(~r/^[A-Z0-9]+$/, element) -> gettext("%{element} (Station)", element: element) # Default case true -> gettext("%{element} (Unknown)", element: element) end end end