aprs.me/lib/aprsme_web/live/info_live/show.ex
Graham McIntire 5ca07bcbe0
Fix info page to show last known position when latest packet has no location data
For stations that send both position and status packets, the info page would show empty location data if the most recent packet was a status packet. This fix ensures the page displays the last known position from the most recent position packet while still showing the latest activity timestamp.
2025-10-03 12:37:45 -05:00

923 lines
28 KiB
Elixir

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("""
<div style="
position: relative;
width: #{size}px;
height: #{size}px;
background-image: url(#{overlay_sprite_info.sprite_file}), url(#{sprite_info.sprite_file});
background-position: #{overlay_sprite_info.background_position}, #{sprite_info.background_position};
background-size: #{overlay_sprite_info.background_size}, #{sprite_info.background_size};
background-repeat: no-repeat, no-repeat;
image-rendering: pixelated;
display: inline-block;
vertical-align: middle;
margin-bottom: -6px;
">
</div>
""")
else
# Use style rendering for non-overlay symbols
raw("""
<div style="#{AprsSymbol.render_style(symbol_table_id, symbol_code, size)}"></div>
""")
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