aprs.me/lib/aprsme_web/live/info_live/show.ex
Graham McInitre 243a98df77 fix: security hardening and query performance improvements
- Session cookie: add encryption_salt and secure flag to endpoint
- CSP: remove unsafe-eval from script-src directive
- /metrics: wrap PromEx endpoint behind rate-limited pipeline
- LiveView signing_salt: move to env var in production config
- LiveView performance: defer heavy aggregation queries past initial mount
- SQL: replace correlated subquery with DISTINCT ON in get_heard_by_stations
2026-07-21 10:13:30 -05:00

717 lines
25 KiB
Elixir

defmodule AprsmeWeb.InfoLive.Show do
@moduledoc false
use AprsmeWeb, :live_view
use Gettext, backend: AprsmeWeb.Gettext
import AprsmeWeb.Components.InfoMapComponent
import Ecto.Query
import Phoenix.HTML, only: [raw: 1]
alias Aprsme.Callsign
alias Aprsme.EncodingUtils
alias Aprsme.GeoUtils
alias Aprsme.Packets
alias Aprsme.Repo
alias AprsmeWeb.AprsSymbol
alias AprsmeWeb.Live.SharedPacketHandler
alias AprsmeWeb.MapLive.PacketUtils
require Logger
@neighbor_limit 10
# APRS Q-construct descriptions (APRS-IS codes)
@q_constructs %{
"qAC" => "qAC (APRS-IS connection)",
"qAO" => "qAO (APRS-IS origin)",
"qAR" => "qAR (APRS-IS relay)",
"qAS" => "qAS (APRS-IS server)",
"qAX" => "qAX (APRS-IS client)",
"qAY" => "qAY (APRS-IS gateway)",
"qAZ" => "qAZ (APRS-IS zone)",
"qBU" => "qBU (APRS-IS user)",
"qBV" => "qBV (APRS-IS vendor)",
"qBW" => "qBW (APRS-IS web)",
"qBX" => "qBX (APRS-IS experimental)",
"qBY" => "qBY (APRS-IS Y2K)",
"qBZ" => "qBZ (APRS-IS Zulu)",
"qCA" => "qCA (APRS-IS client application)",
"qCB" => "qCB (APRS-IS client browser)",
"qCC" => "qCC (APRS-IS client console)",
"qCD" => "qCD (APRS-IS client daemon)",
"qCE" => "qCE (APRS-IS client editor)",
"qCF" => "qCF (APRS-IS client filter)",
"qCG" => "qCG (APRS-IS client gateway)",
"qCH" => "qCH (APRS-IS client host)",
"qCI" => "qCI (APRS-IS client interface)",
"qCJ" => "qCJ (APRS-IS client java)",
"qCK" => "qCK (APRS-IS client kernel)",
"qCL" => "qCL (APRS-IS client library)",
"qCM" => "qCM (APRS-IS client module)",
"qCN" => "qCN (APRS-IS client network)",
"qCO" => "qCO (APRS-IS client object)",
"qCP" => "qCP (APRS-IS client protocol)",
"qCQ" => "qCQ (APRS-IS client query)",
"qCR" => "qCR (APRS-IS client router)",
"qCS" => "qCS (APRS-IS client server)",
"qCT" => "qCT (APRS-IS client terminal)",
"qCU" => "qCU (APRS-IS client user)",
"qCV" => "qCV (APRS-IS client vendor)",
"qCW" => "qCW (APRS-IS client web)",
"qCX" => "qCX (APRS-IS client experimental)",
"qCY" => "qCY (APRS-IS client Y2K)",
"qCZ" => "qCZ (APRS-IS client Zulu)"
}
@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 = Packets.get_other_ssids(normalized_callsign)
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)
send(self(), :load_aggregations)
{: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
@impl true
def handle_info(:load_aggregations, socket) do
locale = Map.get(socket.assigns, :locale, "en")
heard_by_stations = get_heard_by_stations(socket.assigns.callsign, locale)
stations_heard_by = get_stations_heard_by(socket.assigns.callsign, locale)
{:noreply, assign(socket, heard_by_stations: heard_by_stations, stations_heard_by: stations_heard_by)}
end
def handle_info(_message, socket), do: {:noreply, socket}
defp process_packet_update(incoming_packet, socket) do
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
@spec position_changed?(map() | nil, map() | nil) :: boolean()
defp position_changed?(nil, _new_packet), do: true
defp position_changed?(_current_packet, nil), do: false
defp position_changed?(current_packet, new_packet) do
coords_changed?({
EncodingUtils.to_float(current_packet.lat),
EncodingUtils.to_float(current_packet.lon),
EncodingUtils.to_float(new_packet.lat),
EncodingUtils.to_float(new_packet.lon)
})
end
@spec coords_changed?({float() | nil, float() | nil, float() | nil, float() | nil}) :: boolean()
defp coords_changed?({curr_lat, curr_lon, new_lat, new_lon})
when is_number(curr_lat) and is_number(curr_lon) and is_number(new_lat) and is_number(new_lon) do
threshold = Application.get_env(:aprsme, :position_tracking, [])[:change_threshold] || 0.001
abs(curr_lat - new_lat) > threshold or abs(curr_lon - new_lon) > threshold
end
defp coords_changed?(_), do: true
# 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
# Get the most recent packet with valid position data
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
@typep timestamp_display :: %{
time_ago: String.t(),
formatted: String.t(),
timestamp: String.t() | nil
}
@spec haversine(any(), any(), any(), any()) :: float()
def haversine(lat1, lon1, lat2, lon2) do
# Returns distance in km. Decimals are converted to float; nil/invalid inputs become 0.0.
GeoUtils.haversine_distance(to_float(lat1), to_float(lon1), to_float(lat2), to_float(lon2)) / 1000
end
@spec to_float(any()) :: float()
defp to_float(value), do: EncodingUtils.to_float(value) || 0.0
@spec format_timestamp_for_display(map()) :: timestamp_display()
defp format_timestamp_for_display(packet) do
packet
|> get_received_at()
|> AprsmeWeb.TimeHelpers.to_datetime()
|> build_timestamp_display()
end
@spec build_timestamp_display(DateTime.t() | nil) :: timestamp_display()
defp build_timestamp_display(nil), do: %{time_ago: gettext("Unknown"), formatted: "", timestamp: nil}
defp build_timestamp_display(%DateTime{} = dt) do
%{
time_ago: AprsmeWeb.TimeHelpers.time_ago_in_words(dt),
formatted: Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S UTC"),
timestamp: DateTime.to_iso8601(dt)
}
end
@spec get_received_at(map()) :: DateTime.t() | NaiveDateTime.t() | String.t() | integer() | nil
defp get_received_at(%{received_at: value}), do: value
defp get_received_at(%{"received_at" => value}), do: value
defp get_received_at(_), do: nil
# English locale uses imperial (ft/mi), everything else uses metric (m/km).
def format_distance(km, locale \\ "en")
def format_distance(km, "en") do
miles = Aprsme.Convert.kph_to_mph(km)
format_imperial_distance(miles)
end
def format_distance(km, _locale), do: format_metric_distance(km)
defp format_imperial_distance(miles) when miles < 1.0, do: "#{Float.round(miles * 5280, 0)} ft"
defp format_imperial_distance(miles), do: "#{Float.round(miles, 2)} mi"
defp format_metric_distance(km) when km < 1.0, do: "#{Float.round(km * 1000, 0)} m"
defp format_metric_distance(km), do: "#{Float.round(km, 2)} km"
def calculate_course(lat1, lon1, lat2, lon2) do
# 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)
normalize_bearing(:math.atan2(y, x) * 180 / :math.pi())
end
# Convert atan2 output from ±180 to 0-360.
defp normalize_bearing(b) when b < 0, do: b + 360
defp normalize_bearing(b), do: b
defp get_heard_by_stations(callsign, locale) do
# 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 digipeater_location AS (
SELECT DISTINCT ON (sender)
sender as digipeater,
location::geography as dig_loc
FROM packets
WHERE sender IN (
SELECT DISTINCT
substring(path from '([A-Z0-9]+-?[0-9]*)\\*')
FROM packets
WHERE sender = $1
AND received_at >= $2
AND path IS NOT NULL
AND path != ''
AND path !~ '^TCPIP'
AND path !~ ',TCPIP'
AND path ~ '[A-Z0-9]+-?[0-9]*\\*'
)
AND location IS NOT NULL
ORDER BY sender, received_at DESC
),
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'
),
longest_paths AS (
SELECT DISTINCT ON (pp.first_digipeater)
pp.first_digipeater,
pp.id as longest_path_packet_id
FROM parsed_paths pp
WHERE pp.first_digipeater IS NOT NULL
AND pp.lat IS NOT NULL
AND pp.lon IS NOT NULL
ORDER BY pp.first_digipeater,
ST_Distance(
pp.location::geography,
COALESCE(
(SELECT dl.dig_loc FROM digipeater_location dl WHERE dl.digipeater = pp.first_digipeater),
pp.location::geography
)
) DESC NULLS LAST
),
digipeater_stats AS (
SELECT
pp.first_digipeater as digipeater,
MIN(pp.received_at) as first_heard,
MAX(pp.received_at) as last_heard,
COUNT(*) as packet_count,
MAX(lp.longest_path_packet_id) as longest_path_packet_id
FROM parsed_paths pp
LEFT JOIN longest_paths lp ON lp.first_digipeater = pp.first_digipeater
WHERE pp.first_digipeater IS NOT NULL
GROUP BY pp.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 dl.dig_loc IS NOT NULL THEN
ST_Distance(p.location::geography, dl.dig_loc) / 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 digipeater_location dl ON dl.digipeater = ds.digipeater
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 -> map_digipeater_row(row, locale) end)
|> Enum.reject(&is_nil/1)
{:error, error} ->
Logger.error("Error in get_heard_by_stations: #{inspect(error)}")
[]
end
end
defp get_stations_heard_by(callsign, locale) do
# 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 -> map_station_heard_row(row, locale) end)
|> Enum.reject(&is_nil/1)
{:error, error} ->
Logger.error("Error in get_stations_heard_by: #{inspect(error)}")
[]
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)
if symbol_table_id && String.match?(symbol_table_id, ~r/^[A-Z0-9]$/) do
sprite_info = AprsSymbol.get_sprite_info(symbol_table_id, symbol_code)
overlay_sprite_info = AprsSymbol.get_overlay_character_sprite_info(symbol_table_id)
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;"
escaped_style = style |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
raw("<div style=\"#{escaped_style}\"></div>")
else
style = AprsSymbol.render_style(symbol_table_id, symbol_code, size)
escaped_style = style |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
raw("<div style=\"#{escaped_style}\"></div>")
end
else
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)
classify_path_element(linkable_callsign?(element), element)
end
# Only real callsigns (not digipeater aliases or q-constructs) become links.
defp linkable_callsign?(element) do
Regex.match?(~r/^[A-Z0-9]{1,6}(-\d{1,2})?(\*)?$/, element) and
not path_alias?(element)
end
defp path_alias?(element) do
String.starts_with?(element, "WIDE") or
String.starts_with?(element, "TRACE") or
String.starts_with?(element, "RELAY") or
String.starts_with?(element, "TCPIP") or
String.starts_with?(element, "q")
end
# Strip the trailing `*` from the canonical callsign while preserving it
# in the display string so the UI still shows "was-heard-via" markers.
defp classify_path_element(true, element) do
callsign = String.replace(element, "*", "")
{:link, callsign, display_for_element(String.ends_with?(element, "*"), callsign)}
end
defp classify_path_element(false, element), do: {:text, element, element}
defp display_for_element(true, callsign), do: "#{callsign}*"
defp display_for_element(false, callsign), do: callsign
defp decode_path_element(element) do
element = String.trim(element)
cond do
# Check Q-constructs map first (most common)
Map.has_key?(@q_constructs, element) ->
@q_constructs[element]
# WIDE digipeaters
String.starts_with?(element, "WIDE") ->
decode_wide_element(element)
# TRACE digipeaters
String.starts_with?(element, "TRACE") ->
decode_trace_element(element)
# RELAY digipeaters
String.starts_with?(element, "RELAY") ->
decode_relay_element(element)
# TCPIP digipeaters
String.starts_with?(element, "TCPIP") ->
decode_tcpip_element(element)
# 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
defp decode_wide_element("WIDE1-1"), do: gettext("WIDE1-1 (Wide area digipeater, 1 hop)")
defp decode_wide_element("WIDE2-1"), do: gettext("WIDE2-1 (Wide area digipeater, 2 hops)")
defp decode_wide_element("WIDE3-1"), do: gettext("WIDE3-1 (Wide area digipeater, 3 hops)")
defp decode_wide_element("WIDE4-1"), do: gettext("WIDE4-1 (Wide area digipeater, 4 hops)")
defp decode_wide_element("WIDE5-1"), do: gettext("WIDE5-1 (Wide area digipeater, 5 hops)")
defp decode_wide_element("WIDE6-1"), do: gettext("WIDE6-1 (Wide area digipeater, 6 hops)")
defp decode_wide_element("WIDE7-1"), do: gettext("WIDE7-1 (WIDE area digipeater, 7 hops)")
defp decode_wide_element("WIDE1-2"), do: gettext("WIDE1-2 (Wide area digipeater, 1 hop, 2nd attempt)")
defp decode_wide_element("WIDE2-2"), do: gettext("WIDE2-2 (Wide area digipeater, 2 hops, 2nd attempt)")
defp decode_wide_element(element), do: gettext("WIDE digipeater (%{element})", element: element)
defp decode_trace_element("TRACE1-1"), do: gettext("TRACE1-1 (Trace digipeater, 1 hop)")
defp decode_trace_element("TRACE2-1"), do: gettext("TRACE2-1 (Trace digipeater, 2 hops)")
defp decode_trace_element("TRACE3-1"), do: gettext("TRACE3-1 (Trace digipeater, 3 hops)")
defp decode_trace_element("TRACE4-1"), do: gettext("TRACE4-1 (Trace digipeater, 4 hops)")
defp decode_trace_element("TRACE5-1"), do: gettext("TRACE5-1 (Trace digipeater, 5 hops)")
defp decode_trace_element("TRACE6-1"), do: gettext("TRACE6-1 (Trace digipeater, 6 hops)")
defp decode_trace_element("TRACE7-1"), do: gettext("TRACE7-1 (Trace digipeater, 7 hops)")
defp decode_trace_element(element), do: gettext("TRACE digipeater (%{element})", element: element)
defp decode_relay_element("RELAY"), do: gettext("RELAY (Relay digipeater)")
defp decode_relay_element("RELAY-1"), do: gettext("RELAY-1 (Relay digipeater, 1 hop)")
defp decode_relay_element("RELAY-2"), do: gettext("RELAY-2 (Relay digipeater, 2 hops)")
defp decode_relay_element(element), do: gettext("RELAY digipeater (%{element})", element: element)
defp decode_tcpip_element("TCPIP"), do: gettext("TCPIP (Internet gateway)")
defp decode_tcpip_element("TCPIP*"), do: gettext("TCPIP* (Internet gateway, no forward)")
defp decode_tcpip_element(element), do: gettext("TCPIP gateway (%{element})", element: element)
defp map_digipeater_row(row, locale) do
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
defp map_station_heard_row(row, locale) do
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
end