This commit is contained in:
Graham McIntire 2025-06-22 15:25:14 -05:00
parent d0d4c543f2
commit 65ca631115
No known key found for this signature in database
4 changed files with 200 additions and 375 deletions

View file

@ -2,12 +2,10 @@ defmodule AprsWeb.MapLive.CallsignView do
@moduledoc false
use AprsWeb, :live_view
alias Aprs.EncodingUtils
alias Aprs.Packets
alias AprsWeb.Endpoint
alias AprsWeb.MapLive.MapHelpers
alias AprsWeb.MapLive.PacketUtils
alias AprsWeb.TimeHelpers
@default_center %{lat: 39.0, lng: -98.0}
@default_zoom 4
@ -266,61 +264,30 @@ defmodule AprsWeb.MapLive.CallsignView do
def handle_info(:replay_next_packet, socket), do: handle_replay_next_packet(socket)
def handle_info(:cleanup_old_packets, socket), do: handle_cleanup_old_packets(socket)
def handle_info(%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: payload} = msg, socket) do
handle_aprs_packet_broadcast(msg, payload, socket)
def handle_info(%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: packet}, socket) do
# Only process packets for the specific callsign being viewed
if PacketUtils.generate_callsign(packet) == socket.assigns.callsign do
handle_info_postgres_packet(packet, socket)
else
{:noreply, socket}
end
end
def handle_info(_msg, socket), do: {:noreply, socket}
defp handle_aprs_packet_broadcast(_msg, payload, socket) do
sanitized_packet = EncodingUtils.sanitize_packet(payload)
sanitized_packet = Map.put_new(sanitized_packet, :received_at, DateTime.utc_now())
{lat, lng, _} = MapHelpers.get_coordinates(sanitized_packet)
defp handle_info_postgres_packet(packet, socket) do
key = System.unique_integer([:positive])
updated_visible_packets = Map.put(socket.assigns.visible_packets, key, packet)
socket = assign(socket, visible_packets: updated_visible_packets)
callsign_key =
"#{sanitized_packet.base_callsign}#{if sanitized_packet.ssid, do: "-#{sanitized_packet.ssid}", else: ""}"
packet_data = PacketUtils.build_packet_data(packet)
if MapHelpers.has_position_data?(sanitized_packet) and
packet_matches_callsign?(sanitized_packet, socket.assigns.callsign) and
MapHelpers.within_bounds?(%{lat: lat, lon: lng}, socket.assigns.map_bounds) and
packet_within_time_threshold?(
sanitized_packet,
socket.assigns.packet_age_threshold
) do
packet_data = build_packet_data(sanitized_packet)
socket =
if packet_data,
do: push_event(socket, "add_markers", %{markers: [packet_data]}),
else: socket
# Remove any previous marker for this callsign
socket =
if Map.has_key?(socket.assigns.visible_packets, callsign_key) do
push_event(socket, "remove_marker", %{id: callsign_key})
else
socket
end
visible_packets = %{callsign_key => sanitized_packet}
last_known_position =
if lat && lng, do: %{lat: lat, lng: lng}, else: socket.assigns.last_known_position
socket =
socket
|> push_event("new_packet", packet_data)
|> assign(
visible_packets: visible_packets,
last_known_position: last_known_position
)
{:noreply, socket}
else
# Remove marker if it exists and is now out of bounds or expired
if Map.has_key?(socket.assigns.visible_packets, callsign_key) do
socket = push_event(socket, "remove_marker", %{id: callsign_key})
visible_packets = %{}
{:noreply, assign(socket, visible_packets: visible_packets)}
else
{:noreply, socket}
end
end
{:noreply, socket}
end
defp handle_replay_next_packet(socket) do
@ -338,20 +305,8 @@ defmodule AprsWeb.MapLive.CallsignView do
socket.assigns.replay_index < length(socket.assigns.replay_packets)
end
defp handle_replay_packet(nil, socket) do
socket =
assign(socket,
replay_active: false,
replay_paused: false,
replay_timer_ref: nil,
replay_index: 0
)
{:noreply, socket}
end
defp handle_replay_packet(packet, socket) do
case build_packet_data(packet) do
case PacketUtils.build_packet_data(packet) do
nil -> handle_invalid_replay_packet(socket)
packet_data -> handle_valid_replay_packet(packet, packet_data, socket)
end
@ -445,7 +400,7 @@ defmodule AprsWeb.MapLive.CallsignView do
.callsign-header {
position: absolute;
top: 10px;
left: 10px;
left: 60px;
z-index: 1000;
background: rgba(255, 255, 255, 0.95);
border: 2px solid rgba(0,0,0,0.2);
@ -614,6 +569,11 @@ defmodule AprsWeb.MapLive.CallsignView do
max-width: 300px;
}
/* Hides the empty state dialog when not needed */
.empty-state.hidden {
display: none;
}
.empty-state h3 {
margin: 0 0 8px 0;
color: #333;
@ -638,14 +598,15 @@ defmodule AprsWeb.MapLive.CallsignView do
</div>
</div>
<%= if map_size(@visible_packets) == 0 and not @replay_active do %>
<div class="empty-state">
<h3>Loading Historical Data</h3>
<p>
Loading packet history for {@callsign}...
</p>
</div>
<% end %>
<div class={[
"empty-state",
if(map_size(@visible_packets) > 0 or @replay_active, do: "hidden")
]}>
<h3>Loading Historical Data</h3>
<p>
Loading packet history for {@callsign}...
</p>
</div>
<div
id="aprs-map"
@ -749,7 +710,7 @@ defmodule AprsWeb.MapLive.CallsignView do
unique_position_packets
|> Enum.with_index()
|> Enum.map(fn {packet, index} ->
case build_packet_data(packet) do
case PacketUtils.build_packet_data(packet) do
nil ->
nil
@ -794,7 +755,7 @@ defmodule AprsWeb.MapLive.CallsignView do
socket =
if latest_packet && !MapSet.member?(historical_callsigns, latest_packet_id) do
# Push the latest marker as a regular marker (not historical)
packet_data = build_packet_data(latest_packet)
packet_data = PacketUtils.build_packet_data(latest_packet)
if packet_data do
push_event(socket, "add_markers", %{markers: [packet_data]})
@ -876,123 +837,6 @@ defmodule AprsWeb.MapLive.CallsignView do
abs(lat1 - lat2) > 0.00001 || abs(lng1 - lng2) > 0.00001
end
defp build_packet_data(packet) do
{lat, lng, _} = MapHelpers.get_coordinates(packet)
callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))
if lat != nil and lng != nil and callsign != "" and callsign != nil do
build_packet_map(packet, lat, lng, packet.data_extended)
end
end
defp format_popup_timestamp(ts) do
dt =
cond do
is_binary(ts) ->
case DateTime.from_iso8601(ts) do
{:ok, dt, _} -> dt
_ -> DateTime.utc_now()
end
is_integer(ts) ->
DateTime.from_unix!(ts, :millisecond)
match?(%DateTime{}, ts) ->
ts
match?(%NaiveDateTime{}, ts) ->
DateTime.from_naive!(ts, "Etc/UTC")
true ->
DateTime.utc_now()
end
ago = TimeHelpers.time_ago_in_words(dt)
abs_time = Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC")
{ago, abs_time}
end
defp build_packet_map(packet, lat, lng, data_extended) do
data_extended = data_extended || %{}
callsign = PacketUtils.generate_callsign(packet)
{symbol_table_id, symbol_code} = PacketUtils.get_symbol_info(packet)
timestamp = PacketUtils.get_timestamp(packet)
comment = PacketUtils.get_packet_field(packet, :comment, "")
{ago, abs_time} = format_popup_timestamp(timestamp)
popup = """
<div class=\"aprs-popup\">
<div class=\"aprs-callsign\"><strong><a href=\"/#{callsign}\">#{callsign}</a></strong></div>
<div class=\"aprs-symbol-info\">Symbol: #{symbol_table_id}#{symbol_code}</div>
#{if comment == "", do: "", else: "<div class=\\\"aprs-comment\\\">#{comment}</div>"}
<div class=\"aprs-coords\">#{Float.round(PacketUtils.to_float(lat), 4)}, #{Float.round(PacketUtils.to_float(lng), 4)}</div>
<div class=\"aprs-timestamp\"><div>#{ago}</div><div>#{abs_time}</div></div>
</div>
"""
%{
"id" => callsign,
"callsign" => callsign,
"base_callsign" => PacketUtils.get_packet_field(packet, :base_callsign, ""),
"ssid" => PacketUtils.get_packet_field(packet, :ssid, 0),
"lat" => PacketUtils.to_float(lat),
"lng" => PacketUtils.to_float(lng),
"data_type" => to_string(PacketUtils.get_packet_field(packet, :data_type, "unknown")),
"path" => PacketUtils.get_packet_field(packet, :path, ""),
"comment" => comment,
"data_extended" => PacketUtils.convert_tuples_to_strings(data_extended || %{}),
"symbol_table_id" => symbol_table_id,
"symbol_code" => symbol_code,
"symbol_description" => "Symbol: #{symbol_table_id}#{symbol_code}",
"timestamp" => timestamp,
"popup" => popup
}
end
defp packet_matches_callsign?(packet, target_callsign) do
normalized_packet = normalize_packet_callsign(packet)
normalized_target = normalize_target_callsign(target_callsign)
exact_or_base_match?(normalized_packet, normalized_target, packet)
end
defp normalize_packet_callsign(packet) do
base_callsign = packet[:base_callsign] || packet.base_callsign || ""
ssid = packet[:ssid] || packet.ssid
case_result =
case ssid do
nil -> base_callsign
"" -> base_callsign
"0" -> base_callsign
_ -> "#{base_callsign}-#{ssid}"
end
case_result
|> String.upcase()
|> String.trim()
end
defp normalize_target_callsign(target_callsign) do
target_callsign
|> String.upcase()
|> String.trim()
end
defp exact_or_base_match?(normalized_packet, normalized_target, packet) do
cond do
normalized_packet == normalized_target ->
true
not String.contains?(normalized_target, "-") ->
base_callsign = packet[:base_callsign] || packet.base_callsign || ""
String.upcase(String.trim(base_callsign)) == normalized_target
true ->
false
end
end
defp load_callsign_packets(socket, callsign) do
latest_packet =
%{callsign: callsign}
@ -1042,7 +886,7 @@ defmodule AprsWeb.MapLive.CallsignView do
defp maybe_push_latest_marker(socket, nil), do: socket
defp maybe_push_latest_marker(socket, packet) do
packet_data = build_packet_data(packet)
packet_data = PacketUtils.build_packet_data(packet)
if packet_data, do: push_event(socket, "add_markers", %{markers: [packet_data]}), else: socket
end
end

View file

@ -4,6 +4,8 @@ defmodule AprsWeb.MapLive.Index do
"""
use AprsWeb, :live_view
import AprsWeb.TimeHelpers, only: [time_ago_in_words: 1]
alias AprsWeb.Endpoint
alias AprsWeb.MapLive.MapHelpers
alias AprsWeb.MapLive.PacketUtils
@ -195,7 +197,7 @@ defmodule AprsWeb.MapLive.Index do
visible_packets_list =
filtered_packets
|> Enum.map(fn {_callsign, packet} -> build_packet_data(packet) end)
|> Enum.map(fn {_callsign, packet} -> PacketUtils.build_packet_data(packet) end)
|> Enum.filter(& &1)
socket = assign(socket, visible_packets: filtered_packets)
@ -273,7 +275,7 @@ defmodule AprsWeb.MapLive.Index do
{:noreply, socket}
else
# Navigate to the callsign-specific route
{:noreply, push_navigate(socket, to: "/#{trimmed_callsign}")}
{:noreply, push_navigate(socket, to: "/map/callsign/#{trimmed_callsign}")}
end
end
@ -307,7 +309,7 @@ defmodule AprsWeb.MapLive.Index do
@spec handle_bounds_update(map(), Socket.t()) :: {:noreply, Socket.t()}
defp handle_bounds_update(bounds, socket) do
# Update the map bounds from the client
# Update the map bounds from the client, converting to atom keys
map_bounds = %{
north: bounds["north"],
south: bounds["south"],
@ -324,7 +326,6 @@ defmodule AprsWeb.MapLive.Index do
# Only schedule a bounds update if the bounds have actually changed (with rounding)
if compare_bounds(map_bounds, socket.assigns.map_bounds) do
{:noreply, socket}
# Cancel any pending bounds update timer
else
if socket.assigns[:bounds_update_timer] do
Process.cancel_timer(socket.assigns.bounds_update_timer)
@ -495,7 +496,7 @@ defmodule AprsWeb.MapLive.Index do
else: System.unique_integer([:positive])
new_visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, packet)
marker_data = build_packet_data(packet)
marker_data = PacketUtils.build_packet_data(packet)
socket =
if marker_data do
@ -903,38 +904,6 @@ defmodule AprsWeb.MapLive.Index do
</p>
</div>
<!-- Navigation Links (Mobile) -->
<div class="lg:hidden pt-4 border-t border-slate-200 space-y-3">
<.link
navigate={~p"/packets"}
class="flex items-center space-x-2 text-sm text-blue-600 hover:text-blue-800 transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
<span>View All Packets</span>
</.link>
<.link
navigate={~p"/badpackets"}
class="flex items-center space-x-2 text-sm text-blue-600 hover:text-blue-800 transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<span>View Bad Packets</span>
</.link>
</div>
<!-- Deployment Information -->
<div class="pt-4 border-t border-slate-200 space-y-3">
<div class="flex items-center space-x-2 text-sm text-slate-600">
@ -1079,7 +1048,7 @@ defmodule AprsWeb.MapLive.Index do
packet_data_list =
historical_packets
|> Enum.group_by(&generate_callsign/1)
|> Enum.group_by(&PacketUtils.generate_callsign/1)
|> Enum.flat_map(fn {callsign, packets} ->
sorted_packets =
Enum.sort_by(
@ -1120,7 +1089,7 @@ defmodule AprsWeb.MapLive.Index do
end
defp build_historical_packet_data(packet, index, callsign) do
case build_packet_data(packet) do
case PacketUtils.build_packet_data(packet) do
nil ->
nil
@ -1208,7 +1177,7 @@ defmodule AprsWeb.MapLive.Index do
packets = packets_module.get_packets_for_replay(packets_params)
# Sort packets by received_at timestamp to ensure chronological replay
Enum.sort_by(packets, fn packet -> packet.received_at end)
Enum.sort_by(packets, & &1.received_at)
end
@spec load_historical_packets_for_bounds(Socket.t(), map()) :: Socket.t()
@ -1258,146 +1227,6 @@ defmodule AprsWeb.MapLive.Index do
end
end
@spec build_packet_data(map() | struct()) :: map() | nil
defp build_packet_data(packet) do
{lat, lon, data_extended} = MapHelpers.get_coordinates(packet)
callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))
# Only include packets with valid position data and a non-empty callsign
if lat && lon && callsign != "" && callsign != nil do
build_packet_map(packet, lat, lon, data_extended)
end
end
@spec build_packet_map(map() | struct(), number(), number(), map() | nil) :: map()
defp build_packet_map(packet, lat, lon, data_extended) do
data_extended = data_extended || %{}
packet_info = extract_packet_info(packet, data_extended)
popup = build_popup_content(packet, packet_info, lat, lon)
build_packet_result(packet, packet_info, lat, lon, popup)
end
defp extract_packet_info(packet, data_extended) do
%{
callsign: PacketUtils.generate_callsign(packet),
symbol_table_id: PacketUtils.get_packet_field(packet, :symbol_table_id, "/"),
symbol_code: PacketUtils.get_packet_field(packet, :symbol_code, ">"),
timestamp: PacketUtils.get_timestamp(packet),
comment: PacketUtils.get_packet_field(packet, :comment, ""),
safe_data_extended: PacketUtils.convert_tuples_to_strings(data_extended),
is_weather_packet: PacketUtils.weather_packet?(packet)
}
end
defp build_popup_content(packet, packet_info, lat, lon) do
if packet_info.is_weather_packet do
build_weather_popup_html(packet, packet_info.callsign)
else
build_standard_popup_html(packet_info, lat, lon)
end
end
defp build_standard_popup_html(packet_info, lat, lon) do
comment_html =
if packet_info.comment == "",
do: "",
else: ~s(<div class="aprs-comment">#{packet_info.comment}</div>)
"""
<div class="aprs-popup">
<div class="aprs-callsign"><strong><a href="/map/callsign/#{packet_info.callsign}">#{packet_info.callsign}</a></strong></div>
#{comment_html}
<div class="aprs-coords">#{Float.round(PacketUtils.to_float(lat), 4)}, #{Float.round(PacketUtils.to_float(lon), 4)}</div>
<div class="aprs-time">#{format_popup_timestamp(packet_info.timestamp)}</div>
</div>
"""
end
defp build_packet_result(packet, packet_info, lat, lon, popup) do
%{
"id" => packet_info.callsign,
"callsign" => packet_info.callsign,
"base_callsign" => PacketUtils.get_packet_field(packet, :base_callsign, ""),
"ssid" => PacketUtils.get_packet_field(packet, :ssid, 0),
"lat" => PacketUtils.to_float(lat),
"lng" => PacketUtils.to_float(lon),
"data_type" => to_string(PacketUtils.get_packet_field(packet, :data_type, "unknown")),
"path" => PacketUtils.get_packet_field(packet, :path, ""),
"comment" => packet_info.comment,
"data_extended" => packet_info.safe_data_extended || %{},
"symbol_table_id" => packet_info.symbol_table_id,
"symbol_code" => packet_info.symbol_code,
"symbol_description" => "Symbol: #{packet_info.symbol_table_id}#{packet_info.symbol_code}",
"timestamp" => packet_info.timestamp,
"popup" => popup
}
end
defp build_weather_popup_html(packet, callsign) do
received_at = get_received_at(packet)
timestamp_str = format_popup_timestamp(received_at)
"""
<strong>#{callsign} - Weather Report</strong><br>
<small>#{timestamp_str}</small>
<hr>
Temperature: #{PacketUtils.get_weather_field(packet, :temperature)}°F<br>
Humidity: #{PacketUtils.get_weather_field(packet, :humidity)}%<br>
Wind: #{PacketUtils.get_weather_field(packet, :wind_direction)}° at #{PacketUtils.get_weather_field(packet, :wind_speed)} mph, gusts to #{PacketUtils.get_weather_field(packet, :wind_gust)} mph<br>
Pressure: #{PacketUtils.get_weather_field(packet, :pressure)} hPa<br>
Rain (1h): #{PacketUtils.get_weather_field(packet, :rain_1h)} in.<br>
Rain (24h): #{PacketUtils.get_weather_field(packet, :rain_24h)} in.<br>
Rain (since midnight): #{PacketUtils.get_weather_field(packet, :rain_since_midnight)} in.<br>
"""
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
defp format_popup_timestamp(ts) do
cond do
is_binary(ts) ->
case DateTime.from_iso8601(ts) do
{:ok, dt, _} -> Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S UTC")
_ -> ts
end
is_integer(ts) ->
ts
|> DateTime.from_unix!(:millisecond)
|> Calendar.strftime("%Y-%m-%d %H:%M:%S UTC")
match?(%DateTime{}, ts) ->
Calendar.strftime(ts, "%Y-%m-%d %H:%M:%S UTC")
match?(%NaiveDateTime{}, ts) ->
ts
|> DateTime.from_naive!("Etc/UTC")
|> Calendar.strftime("%Y-%m-%d %H:%M:%S UTC")
true ->
to_string(ts)
end
end
@spec generate_callsign(map() | struct()) :: String.t()
defp generate_callsign(packet) do
base_callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))
ssid = Map.get(packet, :ssid, Map.get(packet, "ssid", 0))
if ssid != 0 and ssid != "" and ssid != nil do
"#{base_callsign}-#{ssid}"
else
base_callsign
end
end
# Get location from IP using ip-api.com
@spec get_ip_location(String.t() | nil) :: {float(), float()} | nil
defp get_ip_location(nil), do: nil
@ -1469,7 +1298,4 @@ defmodule AprsWeb.MapLive.Index do
round4.(Map.get(b1, key)) == round4.(Map.get(b2, key))
end)
end
# Use shared helper
defp time_ago_in_words(datetime), do: AprsWeb.TimeHelpers.time_ago_in_words(datetime)
end

View file

@ -3,6 +3,9 @@ defmodule AprsWeb.MapLive.PacketUtils do
Shared utilities for extracting and processing packet data in map views.
"""
alias AprsWeb.MapLive.MapHelpers
alias AprsWeb.TimeHelpers
@doc """
Safely extracts a value from a packet or data_extended map with fallback support.
Checks both atom and string keys, and provides a default value.
@ -134,4 +137,129 @@ defmodule AprsWeb.MapLive.PacketUtils do
Map.get(data_extended, key) ||
Map.get(data_extended, to_string(key)) || "N/A"
end
def build_packet_data(packet) do
{lat, lon, data_extended} = MapHelpers.get_coordinates(packet)
callsign = generate_callsign(packet)
if lat && lon && callsign != "" && callsign != nil do
build_packet_map(packet, lat, lon, data_extended)
end
end
defp build_packet_map(packet, lat, lon, data_extended) do
data_extended = data_extended || %{}
packet_info = extract_packet_info(packet, data_extended)
popup = build_popup_content(packet, packet_info, lat, lon)
build_packet_result(packet, packet_info, lat, lon, popup)
end
defp extract_packet_info(packet, data_extended) do
%{
callsign: generate_callsign(packet),
symbol_table_id: get_packet_field(packet, :symbol_table_id, "/"),
symbol_code: get_packet_field(packet, :symbol_code, ">"),
timestamp: get_timestamp(packet),
comment: get_packet_field(packet, :comment, ""),
safe_data_extended: convert_tuples_to_strings(data_extended),
is_weather_packet: weather_packet?(packet)
}
end
defp build_popup_content(packet, packet_info, lat, lon) do
if packet_info.is_weather_packet do
build_weather_popup_html(packet, packet_info.callsign)
else
build_standard_popup_html(packet_info, lat, lon)
end
end
defp build_standard_popup_html(packet_info, lat, lon) do
comment_html =
if packet_info.comment == "",
do: "",
else: ~s(<div class="aprs-comment">#{packet_info.comment}</div>)
timestamp_dt = TimeHelpers.to_datetime(packet_info.timestamp)
timestamp_html =
if timestamp_dt do
"""
<div class="aprs-timestamp">
<div>#{TimeHelpers.time_ago_in_words(timestamp_dt)}</div>
<div class="text-slate-400">#{Calendar.strftime(timestamp_dt, "%Y-%m-%d %H:%M:%S UTC")}</div>
</div>
"""
else
""
end
"""
<div class="aprs-popup">
<div class="aprs-callsign"><strong><a href="/map/callsign/#{packet_info.callsign}">#{packet_info.callsign}</a></strong></div>
#{comment_html}
<div class="aprs-coords">#{Float.round(to_float(lat), 4)}, #{Float.round(to_float(lon), 4)}</div>
#{timestamp_html}
</div>
"""
end
defp build_weather_popup_html(packet, callsign) do
received_at = get_received_at(packet)
timestamp_dt = TimeHelpers.to_datetime(received_at)
timestamp_html =
if timestamp_dt do
"""
<div class="aprs-timestamp" style="font-size: 11px; color: #6b7280; padding-top: 0; padding-bottom: 4px;">
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; line-height: 1.4;">#{TimeHelpers.time_ago_in_words(timestamp_dt)}</div>
<div style="font-family: monospace;">#{Calendar.strftime(timestamp_dt, "%Y-%m-%d %H:%M:%S UTC")}</div>
</div>
"""
else
""
end
"""
<strong>#{callsign} - Weather Report</strong>
#{timestamp_html}
<hr style="margin-top: 4px; margin-bottom: 4px;">
Temperature: #{get_weather_field(packet, :temperature)}°F<br>
Humidity: #{get_weather_field(packet, :humidity)}%<br>
Wind: #{get_weather_field(packet, :wind_direction)}° at #{get_weather_field(packet, :wind_speed)} mph, gusts to #{get_weather_field(packet, :wind_gust)} mph<br>
Pressure: #{get_weather_field(packet, :pressure)} hPa<br>
Rain (1h): #{get_weather_field(packet, :rain_1h)} in.<br>
Rain (24h): #{get_weather_field(packet, :rain_24h)} in.<br>
Rain (since midnight): #{get_weather_field(packet, :rain_since_midnight)} in.<br>
"""
end
defp build_packet_result(packet, packet_info, lat, lon, popup) do
%{
"id" => packet_info.callsign,
"callsign" => packet_info.callsign,
"base_callsign" => get_packet_field(packet, :base_callsign, ""),
"ssid" => get_packet_field(packet, :ssid, 0),
"lat" => to_float(lat),
"lng" => to_float(lon),
"data_type" => to_string(get_packet_field(packet, :data_type, "unknown")),
"path" => get_packet_field(packet, :path, ""),
"comment" => packet_info.comment,
"data_extended" => packet_info.safe_data_extended || %{},
"symbol_table_id" => packet_info.symbol_table_id,
"symbol_code" => packet_info.symbol_code,
"symbol_description" => "Symbol: #{packet_info.symbol_table_id}#{packet_info.symbol_code}",
"timestamp" => packet_info.timestamp,
"popup" => popup
}
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
end

View file

@ -26,4 +26,31 @@ defmodule AprsWeb.TimeHelpers do
defp format_time_diff(seconds) when seconds < 63_072_000, do: "1 year"
defp format_time_diff(seconds), do: "#{div(seconds, 31_536_000)} years"
@doc """
Converts various timestamp formats into a standard DateTime object.
Handles ISO 8601 strings, Unix timestamps in milliseconds, and existing
DateTime or NaiveDateTime structs.
"""
def to_datetime(ts) do
cond do
is_binary(ts) ->
case DateTime.from_iso8601(ts) do
{:ok, dt, _} -> dt
_ -> nil
end
is_integer(ts) ->
DateTime.from_unix!(ts, :millisecond)
match?(%DateTime{}, ts) ->
ts
match?(%NaiveDateTime{}, ts) ->
DateTime.from_naive!(ts, "Etc/UTC")
true ->
nil
end
end
end