weather fixing
This commit is contained in:
parent
3308fb1922
commit
f08330b46c
17 changed files with 914 additions and 107 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -40,3 +40,5 @@ k8s/secrets.yaml
|
|||
|
||||
/.horusec
|
||||
|
||||
/vendor
|
||||
|
||||
|
|
|
|||
|
|
@ -919,6 +919,9 @@ let MapAPRSMap = {
|
|||
// Update popup if provided
|
||||
if (data.popup) {
|
||||
existingMarker.setPopupContent(data.popup);
|
||||
// Force popup to refresh by unbinding and rebinding
|
||||
existingMarker.unbindPopup();
|
||||
existingMarker.bindPopup(data.popup);
|
||||
}
|
||||
} else {
|
||||
// Marker doesn't exist, create it
|
||||
|
|
@ -1178,7 +1181,7 @@ let MapAPRSMap = {
|
|||
// const symbolDesc = data.symbol_description || `Symbol: ${symbolTableId}${symbolCode}`;
|
||||
|
||||
let content = `<div class="aprs-popup">
|
||||
<div class="aprs-callsign"><strong><a href="/${callsign}">${callsign}</a></strong></div>`;
|
||||
<div class="aprs-callsign"><strong><a href="/${callsign}">${callsign}</a></strong> <a href="/info/${callsign}" class="aprs-info-link">info</a></div>`;
|
||||
// Removed symbol info from popup
|
||||
// content += `<div class="aprs-symbol-info">${symbolDesc}</div>`;
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ defmodule Aprsme.Application do
|
|||
# Start Oban for background jobs
|
||||
{Oban, :aprsme |> Application.get_env(Oban, []) |> Keyword.put(:queues, default: 10, maintenance: 2)},
|
||||
Aprsme.Presence,
|
||||
Aprsme.AprsIsConnection,
|
||||
Aprsme.PostgresNotifier,
|
||||
# Start the packet processing pipeline
|
||||
Aprsme.PacketPipelineSupervisor,
|
||||
|
|
@ -41,6 +40,7 @@ defmodule Aprsme.Application do
|
|||
]
|
||||
|
||||
children = maybe_add_is_supervisor(children, Application.get_env(:aprsme, :env))
|
||||
children = maybe_add_aprs_connection(children, Application.get_env(:aprsme, :env))
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
# for other strategies and supported options
|
||||
|
|
@ -78,11 +78,25 @@ defmodule Aprsme.Application do
|
|||
:ok
|
||||
end
|
||||
|
||||
defp maybe_add_is_supervisor(children, env) when env in [:prod, :dev] do
|
||||
children ++ [Aprsme.Is.IsSupervisor]
|
||||
defp maybe_add_is_supervisor(children, env) do
|
||||
disable_connection = Application.get_env(:aprsme, :disable_aprs_connection, false)
|
||||
|
||||
if env in [:prod, :dev] and not disable_connection do
|
||||
children ++ [Aprsme.Is.IsSupervisor]
|
||||
else
|
||||
children
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_add_is_supervisor(children, _env), do: children
|
||||
defp maybe_add_aprs_connection(children, _env) do
|
||||
disable_connection = Application.get_env(:aprsme, :disable_aprs_connection, false)
|
||||
|
||||
if disable_connection do
|
||||
children
|
||||
else
|
||||
children ++ [Aprsme.AprsIsConnection]
|
||||
end
|
||||
end
|
||||
|
||||
defp do_migrate(true) do
|
||||
require Logger
|
||||
|
|
|
|||
|
|
@ -34,8 +34,16 @@ defmodule Aprsme.AprsIsConnection do
|
|||
# GenServer callbacks
|
||||
@impl true
|
||||
def init(state) do
|
||||
schedule_connect(0)
|
||||
{:ok, Map.merge(%{socket: nil, backoff: @reconnect_initial}, state)}
|
||||
# Check if APRS connection is disabled (e.g., in test mode)
|
||||
disable_connection = Application.get_env(:aprsme, :disable_aprs_connection, false)
|
||||
|
||||
if disable_connection do
|
||||
Logger.info("APRS-IS connection disabled (test mode)")
|
||||
{:ok, Map.merge(%{socket: nil, backoff: @reconnect_initial}, state)}
|
||||
else
|
||||
schedule_connect(0)
|
||||
{:ok, Map.merge(%{socket: nil, backoff: @reconnect_initial}, state)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
|
|||
|
|
@ -42,8 +42,10 @@ defmodule Aprsme.Packet do
|
|||
field(:rain_1h, :float)
|
||||
field(:rain_24h, :float)
|
||||
field(:rain_since_midnight, :float)
|
||||
field(:snow, :float)
|
||||
|
||||
# Equipment/status information
|
||||
field(:luminosity, :integer)
|
||||
field(:manufacturer, :string)
|
||||
field(:equipment_type, :string)
|
||||
field(:course, :integer)
|
||||
|
|
@ -100,6 +102,8 @@ defmodule Aprsme.Packet do
|
|||
:rain_1h,
|
||||
:rain_24h,
|
||||
:rain_since_midnight,
|
||||
:snow,
|
||||
:luminosity,
|
||||
:manufacturer,
|
||||
:equipment_type,
|
||||
:course,
|
||||
|
|
@ -239,6 +243,10 @@ defmodule Aprsme.Packet do
|
|||
# Start with the base attributes and add the raw packet
|
||||
base_attrs = Map.put(attrs, :raw_packet, raw_packet)
|
||||
|
||||
# Remove raw_weather_data from base attributes
|
||||
base_attrs = Map.delete(base_attrs, :raw_weather_data)
|
||||
base_attrs = Map.delete(base_attrs, "raw_weather_data")
|
||||
|
||||
# Extract data based on the type of data_extended
|
||||
additional_data =
|
||||
case data_extended do
|
||||
|
|
@ -248,6 +256,10 @@ defmodule Aprsme.Packet do
|
|||
%{__original_struct__: MicE} = mic_e_map ->
|
||||
extract_from_mic_e_map(mic_e_map)
|
||||
|
||||
# Handle ParseError structs gracefully
|
||||
%{__struct__: Aprs.Types.ParseError} ->
|
||||
%{}
|
||||
|
||||
%{} when is_map(data_extended) ->
|
||||
extract_from_map(data_extended)
|
||||
|
||||
|
|
@ -259,19 +271,41 @@ defmodule Aprsme.Packet do
|
|||
end
|
||||
|
||||
# Extract data from standard map-based data_extended
|
||||
defp extract_from_map(data_extended) do
|
||||
# Convert struct to map if possible, otherwise return empty map
|
||||
defp extract_from_map(data_extended) when is_struct(data_extended) do
|
||||
data_extended
|
||||
|> Map.from_struct()
|
||||
|> extract_from_map()
|
||||
rescue
|
||||
_ -> %{}
|
||||
end
|
||||
|
||||
defp extract_from_map(data_extended) when is_map(data_extended) do
|
||||
# Remove raw_weather_data before processing
|
||||
data_extended = Map.delete(data_extended, :raw_weather_data)
|
||||
data_extended = Map.delete(data_extended, "raw_weather_data")
|
||||
|
||||
# Handle nested data_extended structures
|
||||
nested_data_extended = data_extended[:data_extended] || data_extended["data_extended"]
|
||||
|
||||
# Merge data from both levels
|
||||
combined_data =
|
||||
if nested_data_extended do
|
||||
Map.merge(data_extended, nested_data_extended)
|
||||
else
|
||||
data_extended
|
||||
end
|
||||
|
||||
%{}
|
||||
|> put_symbol_fields(data_extended)
|
||||
|> put_weather_fields(data_extended)
|
||||
|> put_equipment_fields(data_extended)
|
||||
|> put_message_fields(data_extended)
|
||||
|> extract_weather_data(data_extended)
|
||||
|> put_symbol_fields(combined_data)
|
||||
|> extract_weather_data(combined_data)
|
||||
|> put_weather_fields(combined_data)
|
||||
|> put_equipment_fields(combined_data)
|
||||
|> put_message_fields(combined_data)
|
||||
end
|
||||
|
||||
defp extract_from_map(_), do: %{}
|
||||
|
||||
defp put_symbol_fields(map, data_extended) do
|
||||
map
|
||||
|> maybe_put(:symbol_code, data_extended[:symbol_code] || data_extended["symbol_code"])
|
||||
|
|
@ -297,7 +331,8 @@ defmodule Aprsme.Packet do
|
|||
:pressure,
|
||||
:rain_1h,
|
||||
:rain_24h,
|
||||
:rain_since_midnight
|
||||
:rain_since_midnight,
|
||||
:snow
|
||||
]
|
||||
|
||||
Enum.reduce(weather_fields, map, fn field, acc ->
|
||||
|
|
@ -361,57 +396,49 @@ defmodule Aprsme.Packet do
|
|||
# Look for weather report in different possible locations
|
||||
weather_data =
|
||||
data_extended[:weather] || data_extended["weather"] ||
|
||||
data_extended[:weather_report] || data_extended["weather_report"]
|
||||
data_extended[:weather_report] || data_extended["weather_report"] ||
|
||||
data_extended[:raw_weather_data] || data_extended["raw_weather_data"]
|
||||
|
||||
# Also check the comment field for weather data
|
||||
comment_weather = attrs[:comment] || attrs["comment"]
|
||||
|
||||
case weather_data do
|
||||
weather when is_binary(weather) ->
|
||||
parse_weather_string(attrs, weather)
|
||||
case Aprs.Weather.parse(weather) do
|
||||
nil ->
|
||||
attrs
|
||||
|
||||
parsed_weather ->
|
||||
attrs
|
||||
|> Map.merge(parsed_weather)
|
||||
|> Map.put(:data_type, "weather")
|
||||
end
|
||||
|
||||
weather when is_map(weather) ->
|
||||
weather = Map.drop(weather, [:raw_weather_data, "raw_weather_data"])
|
||||
Map.merge(attrs, weather)
|
||||
|
||||
attrs
|
||||
|> Map.merge(weather)
|
||||
|> Map.put(:data_type, "weather")
|
||||
|
||||
_ ->
|
||||
attrs
|
||||
# If no weather data in data_extended, try parsing the comment
|
||||
if is_binary(comment_weather) do
|
||||
case Aprs.Weather.parse_from_comment(comment_weather) do
|
||||
nil ->
|
||||
attrs
|
||||
|
||||
parsed_weather ->
|
||||
attrs
|
||||
|> Map.merge(parsed_weather)
|
||||
|> Map.put(:data_type, "weather")
|
||||
end
|
||||
else
|
||||
attrs
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Parse weather data from string format (basic implementation)
|
||||
defp parse_weather_string(attrs, weather_string) do
|
||||
# This is a simplified parser - a full implementation would handle
|
||||
# the complete APRS weather format specification
|
||||
attrs
|
||||
|> maybe_extract_weather_field(weather_string, ~r/(\d{3})\/(\d{3})/, [
|
||||
:wind_direction,
|
||||
:wind_speed
|
||||
])
|
||||
|> maybe_extract_weather_field(weather_string, ~r/t(\d{3})/, [:temperature])
|
||||
|> maybe_extract_weather_field(weather_string, ~r/h(\d{2})/, [:humidity])
|
||||
|> maybe_extract_weather_field(weather_string, ~r/b(\d{5})/, [:pressure])
|
||||
end
|
||||
|
||||
# Helper to extract weather fields using regex
|
||||
defp maybe_extract_weather_field(attrs, weather_string, regex, keys) do
|
||||
case Regex.run(regex, weather_string) do
|
||||
[_full | matches] ->
|
||||
update_attrs_with_weather_matches(attrs, keys, matches)
|
||||
|
||||
_ ->
|
||||
attrs
|
||||
end
|
||||
end
|
||||
|
||||
defp update_attrs_with_weather_matches(attrs, keys, matches) do
|
||||
keys
|
||||
|> Enum.zip(matches)
|
||||
|> Enum.reduce(attrs, fn {key, value}, acc ->
|
||||
case Integer.parse(value) do
|
||||
{int_val, _} -> Map.put(acc, key, int_val)
|
||||
:error -> acc
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# Helper to put a value only if it's not nil
|
||||
defp maybe_put(map, _key, nil), do: map
|
||||
defp maybe_put(map, _key, ""), do: map
|
||||
|
|
|
|||
|
|
@ -152,6 +152,9 @@ defmodule Aprsme.PacketConsumer do
|
|||
|> sanitize_raw_packet()
|
||||
|> then(fn attrs -> Map.new(attrs, fn {k, v} -> {k, sanitize_packet_strings(v)} end) end)
|
||||
|> truncate_datetimes_to_second()
|
||||
# Explicitly remove raw_weather_data to prevent insert_all errors
|
||||
|> Map.delete(:raw_weather_data)
|
||||
|> Map.delete("raw_weather_data")
|
||||
rescue
|
||||
error ->
|
||||
Logger.error("Failed to prepare packet for batch insert: #{inspect(error)}")
|
||||
|
|
@ -350,6 +353,7 @@ defmodule Aprsme.PacketConsumer do
|
|||
:rain_1h,
|
||||
:rain_24h,
|
||||
:rain_since_midnight,
|
||||
:snow,
|
||||
:speed,
|
||||
:altitude
|
||||
]
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ defmodule Aprsme.Packets do
|
|||
try do
|
||||
packet_attrs =
|
||||
packet_data
|
||||
|> Aprsme.Packet.extract_additional_data(packet_data[:raw_packet] || packet_data["raw_packet"] || "")
|
||||
|> normalize_packet_attrs()
|
||||
|> set_received_at()
|
||||
|> patch_lat_lon_from_data_extended()
|
||||
|
|
|
|||
104
lib/aprsme_web/controllers/info_controller.ex
Normal file
104
lib/aprsme_web/controllers/info_controller.ex
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
defmodule AprsmeWeb.InfoController do
|
||||
use AprsmeWeb, :controller
|
||||
|
||||
alias Aprsme.Packets
|
||||
alias AprsmeWeb.MapLive.PacketUtils
|
||||
|
||||
@neighbor_radius_km 10
|
||||
@neighbor_limit 10
|
||||
|
||||
def show(conn, %{"callsign" => callsign}) do
|
||||
normalized_callsign = String.upcase(String.trim(callsign))
|
||||
packet = get_latest_packet(normalized_callsign)
|
||||
neighbors = get_neighbors(packet, normalized_callsign)
|
||||
|
||||
render(conn, :show,
|
||||
callsign: normalized_callsign,
|
||||
packet: packet,
|
||||
neighbors: neighbors,
|
||||
page_title: "APRS station #{normalized_callsign}"
|
||||
)
|
||||
end
|
||||
|
||||
defp get_latest_packet(callsign) do
|
||||
%{callsign: callsign, limit: 1}
|
||||
|> Packets.get_recent_packets()
|
||||
|> List.first()
|
||||
end
|
||||
|
||||
defp get_neighbors(nil, _callsign), do: []
|
||||
|
||||
defp get_neighbors(packet, callsign) do
|
||||
lat = packet.lat
|
||||
lon = packet.lon
|
||||
|
||||
if is_nil(lat) or is_nil(lon) do
|
||||
[]
|
||||
else
|
||||
# Simple bounding box for ~10km radius
|
||||
delta = @neighbor_radius_km / 111.0
|
||||
min_lat = lat - delta
|
||||
max_lat = lat + delta
|
||||
min_lon = lon - delta
|
||||
max_lon = lon + delta
|
||||
opts = %{bounds: [min_lon, min_lat, max_lon, max_lat], limit: 50}
|
||||
|
||||
opts
|
||||
|> Packets.get_recent_packets()
|
||||
|> Enum.filter(fn p ->
|
||||
(p.sender != callsign and p.lat) && p.lon
|
||||
end)
|
||||
|> uniq_by(& &1.sender)
|
||||
|> Enum.map(fn p ->
|
||||
dist = haversine(lat, lon, p.lat, p.lon)
|
||||
|
||||
%{
|
||||
callsign: p.sender,
|
||||
distance: format_distance(dist),
|
||||
last_heard: PacketUtils.get_timestamp(p),
|
||||
packet: p
|
||||
}
|
||||
end)
|
||||
|> Enum.sort_by(& &1.distance)
|
||||
|> Enum.take(@neighbor_limit)
|
||||
end
|
||||
end
|
||||
|
||||
defp uniq_by(list, fun) do
|
||||
list
|
||||
|> Enum.reduce({MapSet.new(), []}, fn item, {set, acc} ->
|
||||
key = fun.(item)
|
||||
|
||||
if MapSet.member?(set, key) do
|
||||
{set, acc}
|
||||
else
|
||||
{MapSet.put(set, key), [item | acc]}
|
||||
end
|
||||
end)
|
||||
|> elem(1)
|
||||
|> Enum.reverse()
|
||||
end
|
||||
|
||||
defp haversine(lat1, lon1, lat2, lon2) do
|
||||
# Returns distance in km
|
||||
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
|
||||
|
||||
defp format_distance(km) when km < 1.0 do
|
||||
"#{Float.round(km * 1000, 0)} m"
|
||||
end
|
||||
|
||||
defp format_distance(km) do
|
||||
"#{Float.round(km, 2)} km"
|
||||
end
|
||||
end
|
||||
|
|
@ -2,6 +2,8 @@ defmodule AprsmeWeb.MapLive.CallsignView do
|
|||
@moduledoc false
|
||||
use AprsmeWeb, :live_view
|
||||
|
||||
import Phoenix.LiveView, only: [connected?: 1, push_event: 3]
|
||||
|
||||
alias Aprsme.Packets
|
||||
alias AprsmeWeb.Endpoint
|
||||
alias AprsmeWeb.MapLive.MapHelpers
|
||||
|
|
@ -578,6 +580,22 @@ defmodule AprsmeWeb.MapLive.CallsignView do
|
|||
color: #333;
|
||||
}
|
||||
|
||||
.aprs-info-link {
|
||||
font-size: 11px;
|
||||
color: #007cba;
|
||||
text-decoration: none;
|
||||
font-weight: normal;
|
||||
margin-left: 8px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.aprs-info-link:hover {
|
||||
background-color: rgba(0, 124, 186, 0.1);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.aprs-comment {
|
||||
font-size: 11px;
|
||||
color: #444;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ defmodule AprsmeWeb.MapLive.Index do
|
|||
use AprsmeWeb, :live_view
|
||||
|
||||
import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1]
|
||||
import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_navigate: 2]
|
||||
|
||||
alias AprsmeWeb.Endpoint
|
||||
alias AprsmeWeb.MapLive.MapHelpers
|
||||
|
|
@ -55,11 +56,6 @@ defmodule AprsmeWeb.MapLive.Index do
|
|||
historical_hours: "1",
|
||||
packet_age_threshold: one_hour_ago,
|
||||
slideover_open: true,
|
||||
replay_active: false,
|
||||
replay_start_time: nil,
|
||||
replay_end_time: nil,
|
||||
replay_current_time: nil,
|
||||
replay_speed: 1,
|
||||
deployed_at: deployed_at
|
||||
)}
|
||||
end
|
||||
|
|
@ -432,7 +428,18 @@ defmodule AprsmeWeb.MapLive.Index do
|
|||
|
||||
defp handle_info_initialize_replay(socket) do
|
||||
if not socket.assigns.historical_loaded and socket.assigns.map_ready do
|
||||
socket = start_historical_replay(socket)
|
||||
# Use default bounds if map_bounds is not available yet
|
||||
map_bounds =
|
||||
socket.assigns.map_bounds ||
|
||||
%{
|
||||
north: 90.0,
|
||||
south: -90.0,
|
||||
east: 180.0,
|
||||
west: -180.0
|
||||
}
|
||||
|
||||
socket = assign(socket, map_bounds: map_bounds)
|
||||
socket = load_historical_packets_for_bounds(socket, map_bounds)
|
||||
{:noreply, socket}
|
||||
else
|
||||
{:noreply, socket}
|
||||
|
|
@ -606,6 +613,22 @@ defmodule AprsmeWeb.MapLive.Index do
|
|||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.aprs-info-link {
|
||||
font-size: 11px;
|
||||
color: #007cba;
|
||||
text-decoration: none;
|
||||
font-weight: normal;
|
||||
margin-left: 8px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.aprs-info-link:hover {
|
||||
background-color: rgba(0, 124, 186, 0.1);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.aprs-comment {
|
||||
color: #374151;
|
||||
margin-bottom: 4px;
|
||||
|
|
@ -1030,37 +1053,6 @@ defmodule AprsmeWeb.MapLive.Index do
|
|||
# Helper functions
|
||||
|
||||
# Fetch historical packets from the database
|
||||
# Helper function to start historical replay
|
||||
@spec start_historical_replay(Socket.t()) :: Socket.t()
|
||||
defp start_historical_replay(socket) do
|
||||
if socket.assigns.map_ready and socket.assigns.map_bounds do
|
||||
start_historical_replay_with_bounds(socket)
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp start_historical_replay_with_bounds(socket) do
|
||||
now = DateTime.utc_now()
|
||||
historical_hours = String.to_integer(socket.assigns.historical_hours)
|
||||
start_time = DateTime.add(now, -historical_hours * 3600, :second)
|
||||
|
||||
bounds = [
|
||||
socket.assigns.map_bounds.west,
|
||||
socket.assigns.map_bounds.south,
|
||||
socket.assigns.map_bounds.east,
|
||||
socket.assigns.map_bounds.north
|
||||
]
|
||||
|
||||
historical_packets = fetch_historical_packets(bounds, start_time, now)
|
||||
|
||||
if Enum.empty?(historical_packets) do
|
||||
socket
|
||||
else
|
||||
process_historical_packets(socket, historical_packets)
|
||||
end
|
||||
end
|
||||
|
||||
defp process_historical_packets(socket, historical_packets) do
|
||||
socket = push_event(socket, "clear_historical_packets", %{})
|
||||
|
||||
|
|
@ -1214,7 +1206,7 @@ defmodule AprsmeWeb.MapLive.Index do
|
|||
historical_packets = fetch_historical_packets(bounds, start_time, now)
|
||||
|
||||
if Enum.empty?(historical_packets) do
|
||||
socket
|
||||
assign(socket, historical_loaded: true)
|
||||
else
|
||||
process_historical_packets(socket, historical_packets)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
|
|||
"""
|
||||
@spec get_weather_field(map(), atom()) :: String.t()
|
||||
def get_weather_field(packet, key) do
|
||||
data_extended = Map.get(packet, "data_extended", %{}) || %{}
|
||||
data_extended = Map.get(packet, :data_extended, Map.get(packet, "data_extended", %{})) || %{}
|
||||
|
||||
Map.get(packet, key) ||
|
||||
Map.get(packet, to_string(key)) ||
|
||||
|
|
@ -202,7 +202,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
|
|||
|
||||
"""
|
||||
<div class="aprs-popup">
|
||||
<div class="aprs-callsign"><strong><a href="/#{packet_info.callsign}">#{packet_info.callsign}</a></strong></div>
|
||||
<div class="aprs-callsign"><strong><a href="/#{packet_info.callsign}">#{packet_info.callsign}</a></strong> <a href="/info/#{packet_info.callsign}" class="aprs-info-link">info</a></div>
|
||||
#{comment_html}
|
||||
#{timestamp_html}
|
||||
</div>
|
||||
|
|
@ -225,17 +225,23 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
|
|||
""
|
||||
end
|
||||
|
||||
# Add a unique timestamp to prevent caching
|
||||
cache_buster = System.system_time(:millisecond)
|
||||
|
||||
"""
|
||||
<strong>#{sender} - 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>
|
||||
<div class="aprs-popup" data-timestamp="#{cache_buster}">
|
||||
<div class="aprs-callsign"><strong><a href="/#{sender}">#{sender}</a></strong> <a href="/info/#{sender}" class="aprs-info-link">info</a></div>
|
||||
<div class="aprs-comment">Weather Report</div>
|
||||
#{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>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
|
|
|
|||
13
mix.exs
13
mix.exs
|
|
@ -66,14 +66,13 @@ defmodule Aprsme.MixProject do
|
|||
{:phoenix_html, "~> 4.0"},
|
||||
{:phoenix_live_dashboard, "~> 0.8"},
|
||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||
{:phoenix_live_view, "~> 1.0.2"},
|
||||
{:phoenix_live_view, "~> 1.0.17"},
|
||||
{:plug_cowboy, "~> 2.5"},
|
||||
{:postgrex, ">= 0.0.0"},
|
||||
{:swoosh, "~> 1.3"},
|
||||
{:telemetry_metrics, "~> 1.0"},
|
||||
{:telemetry_poller, "~> 1.0"},
|
||||
# {:aprs, github: "aprsme/aprs", branch: "main"},
|
||||
{:aprs, "~> 0.1.3"},
|
||||
aprs_dep(),
|
||||
{:esbuild, "~> 0.5", runtime: Mix.env() == :dev, config: "assets/esbuild.config.js"},
|
||||
{:tailwind, "~> 0.3.1", runtime: Mix.env() == :dev},
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
|
|
@ -114,4 +113,12 @@ defmodule Aprsme.MixProject do
|
|||
]
|
||||
]
|
||||
end
|
||||
|
||||
defp aprs_dep do
|
||||
if Mix.env() in [:dev, :test] do
|
||||
{:aprs, path: "vendor/aprs"}
|
||||
else
|
||||
{:aprs, github: "aprsme/aprs", branch: "main"}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
defmodule Aprsme.Repo.Migrations.AddLuminosityToPackets do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:packets) do
|
||||
add :luminosity, :integer
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
defmodule Aprsme.Repo.Migrations.AddSnowToPackets do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:packets) do
|
||||
add :snow, :float
|
||||
end
|
||||
end
|
||||
end
|
||||
217
test/aprsme/packets_weather_test.exs
Normal file
217
test/aprsme/packets_weather_test.exs
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
defmodule Aprsme.PacketsWeatherTest do
|
||||
use Aprsme.DataCase
|
||||
|
||||
alias Aprsme.Packets
|
||||
|
||||
describe "store_packet/1 with weather data" do
|
||||
test "removes raw_weather_data from packet attributes before insertion and parses weather string" do
|
||||
# Simulate a real APRS weather packet with correct format
|
||||
raw_packet = "TEST-1>APRS,WIDE1-1,WIDE2-2:!3216.46N/09647.82W_180/010g015t072r000p000h45b10132"
|
||||
|
||||
# Parse the packet using the real APRS parser
|
||||
{:ok, parsed_packet} = Aprs.parse(raw_packet)
|
||||
|
||||
# Create packet data as it would come from the parser
|
||||
packet_data = %{
|
||||
base_callsign: "TEST",
|
||||
ssid: "1",
|
||||
sender: "TEST-1",
|
||||
destination: "APRS",
|
||||
data_type: "position",
|
||||
path: "WIDE1-1,WIDE2-2",
|
||||
information_field: "!3216.46N/09647.82W_180/010g015t072r000p000h45b10132",
|
||||
raw_packet: raw_packet,
|
||||
data_extended: parsed_packet
|
||||
}
|
||||
|
||||
assert {:ok, stored_packet} = Packets.store_packet(packet_data)
|
||||
assert stored_packet.sender == "TEST-1"
|
||||
assert stored_packet.data_type == "weather"
|
||||
refute Map.has_key?(stored_packet, :raw_weather_data)
|
||||
refute Map.has_key?(stored_packet, "raw_weather_data")
|
||||
assert stored_packet.temperature == 72
|
||||
assert stored_packet.humidity == 45
|
||||
assert stored_packet.wind_direction == 180
|
||||
assert stored_packet.wind_speed == 10
|
||||
assert stored_packet.wind_gust == 15
|
||||
assert stored_packet.pressure == 1013.2
|
||||
assert stored_packet.rain_1h == 0
|
||||
end
|
||||
|
||||
test "removes raw_weather_data from weather packets with string weather data" do
|
||||
# Simulate a real APRS weather packet with correct format
|
||||
raw_packet = "WEATHER-1>APRS,WIDE1-1:!3216.46N/09647.82W_270/012g018t085r000p000h60b10150"
|
||||
|
||||
# Parse the packet using the real APRS parser
|
||||
{:ok, parsed_packet} = Aprs.parse(raw_packet)
|
||||
|
||||
packet_data = %{
|
||||
base_callsign: "WEATHER",
|
||||
ssid: "1",
|
||||
sender: "WEATHER-1",
|
||||
destination: "APRS",
|
||||
data_type: "position",
|
||||
path: "WIDE1-1",
|
||||
information_field: "!3216.46N/09647.82W_270/012g018t085r000p000h60b10150",
|
||||
raw_packet: raw_packet,
|
||||
data_extended: parsed_packet
|
||||
}
|
||||
|
||||
assert {:ok, stored_packet} = Packets.store_packet(packet_data)
|
||||
assert stored_packet.data_type == "weather"
|
||||
refute Map.has_key?(stored_packet, :raw_weather_data)
|
||||
refute Map.has_key?(stored_packet, "raw_weather_data")
|
||||
assert stored_packet.temperature == 85
|
||||
assert stored_packet.humidity == 60
|
||||
assert stored_packet.wind_direction == 270
|
||||
assert stored_packet.wind_speed == 12
|
||||
assert stored_packet.wind_gust == 18
|
||||
assert stored_packet.pressure == 1015.0
|
||||
end
|
||||
|
||||
test "removes raw_weather_data from comment-based weather parsing" do
|
||||
# Simulate a real APRS packet with weather data in comment
|
||||
raw_packet = "COMMENT-1>APRS,WIDE1-1:!3216.46N/09647.82W>090/008g012t095r000p000h70b10200"
|
||||
|
||||
# Parse the packet using the real APRS parser
|
||||
{:ok, parsed_packet} = Aprs.parse(raw_packet)
|
||||
|
||||
packet_data = %{
|
||||
base_callsign: "COMMENT",
|
||||
ssid: "1",
|
||||
sender: "COMMENT-1",
|
||||
destination: "APRS",
|
||||
data_type: "position",
|
||||
path: "WIDE1-1",
|
||||
information_field: "!3216.46N/09647.82W>090/008g012t095r000p000h70b10200",
|
||||
raw_packet: raw_packet,
|
||||
data_extended: parsed_packet
|
||||
}
|
||||
|
||||
assert {:ok, stored_packet} = Packets.store_packet(packet_data)
|
||||
assert stored_packet.data_type == "weather"
|
||||
refute Map.has_key?(stored_packet, :raw_weather_data)
|
||||
refute Map.has_key?(stored_packet, "raw_weather_data")
|
||||
assert stored_packet.temperature == 95
|
||||
assert stored_packet.humidity == 70
|
||||
assert stored_packet.wind_direction == 90
|
||||
assert stored_packet.wind_speed == 8
|
||||
assert stored_packet.wind_gust == 12
|
||||
assert stored_packet.pressure == 1020.0
|
||||
end
|
||||
|
||||
test "handles packets without weather data correctly" do
|
||||
# Simulate a real APRS packet without weather data
|
||||
raw_packet = "REGULAR-1>APRS,WIDE1-1:!3216.46N/09647.82W>Just a regular packet"
|
||||
|
||||
# Parse the packet using the real APRS parser
|
||||
{:ok, parsed_packet} = Aprs.parse(raw_packet)
|
||||
|
||||
packet_data = %{
|
||||
base_callsign: "REGULAR",
|
||||
ssid: "1",
|
||||
sender: "REGULAR-1",
|
||||
destination: "APRS",
|
||||
data_type: "position",
|
||||
path: "WIDE1-1",
|
||||
information_field: "!3216.46N/09647.82W>Just a regular packet",
|
||||
raw_packet: raw_packet,
|
||||
data_extended: parsed_packet
|
||||
}
|
||||
|
||||
assert {:ok, stored_packet} = Packets.store_packet(packet_data)
|
||||
assert stored_packet.data_type == "position"
|
||||
assert is_nil(stored_packet.temperature)
|
||||
assert is_nil(stored_packet.humidity)
|
||||
assert is_nil(stored_packet.wind_direction)
|
||||
assert is_nil(stored_packet.wind_speed)
|
||||
assert is_nil(stored_packet.wind_gust)
|
||||
assert is_nil(stored_packet.pressure)
|
||||
assert is_nil(stored_packet.rain_1h)
|
||||
assert is_nil(stored_packet.rain_24h)
|
||||
assert is_nil(stored_packet.rain_since_midnight)
|
||||
refute Map.has_key?(stored_packet, :raw_weather_data)
|
||||
refute Map.has_key?(stored_packet, "raw_weather_data")
|
||||
end
|
||||
|
||||
test "handles batch insert operations correctly" do
|
||||
# Simulate real APRS weather packets with correct format
|
||||
raw_packet1 = "BATCH1-1>APRS,WIDE1-1:!3216.46N/09647.82W_200/010g015t070r000p000h50b10120"
|
||||
raw_packet2 = "BATCH2-1>APRS,WIDE1-1:!3216.46N/09647.82W_220/012g018t075r000p000h55b10140"
|
||||
|
||||
# Parse the packets using the real APRS parser
|
||||
{:ok, parsed_packet1} = Aprs.parse(raw_packet1)
|
||||
{:ok, parsed_packet2} = Aprs.parse(raw_packet2)
|
||||
|
||||
packets_data = [
|
||||
%{
|
||||
base_callsign: "BATCH1",
|
||||
ssid: "1",
|
||||
sender: "BATCH1-1",
|
||||
destination: "APRS",
|
||||
data_type: "position",
|
||||
path: "WIDE1-1",
|
||||
information_field: "!3216.46N/09647.82W_200/010g015t070r000p000h50b10120",
|
||||
raw_packet: raw_packet1,
|
||||
data_extended: parsed_packet1
|
||||
},
|
||||
%{
|
||||
base_callsign: "BATCH2",
|
||||
ssid: "1",
|
||||
sender: "BATCH2-1",
|
||||
destination: "APRS",
|
||||
data_type: "position",
|
||||
path: "WIDE1-1",
|
||||
information_field: "!3216.46N/09647.82W_220/012g018t075r000p000h55b10140",
|
||||
raw_packet: raw_packet2,
|
||||
data_extended: parsed_packet2
|
||||
}
|
||||
]
|
||||
|
||||
results = Enum.map(packets_data, &Packets.store_packet/1)
|
||||
assert Enum.all?(results, fn {status, _} -> status == :ok end)
|
||||
|
||||
Enum.each(results, fn {:ok, stored_packet} ->
|
||||
refute Map.has_key?(stored_packet, :raw_weather_data)
|
||||
refute Map.has_key?(stored_packet, "raw_weather_data")
|
||||
end)
|
||||
|
||||
[first_result, second_result] = results
|
||||
{:ok, first_packet} = first_result
|
||||
{:ok, second_packet} = second_result
|
||||
|
||||
assert first_packet.temperature == 70
|
||||
assert first_packet.humidity == 50
|
||||
assert first_packet.wind_direction == 200
|
||||
assert second_packet.temperature == 75
|
||||
assert second_packet.humidity == 55
|
||||
assert second_packet.wind_direction == 220
|
||||
end
|
||||
|
||||
test "handles snow data correctly" do
|
||||
# Simulate a real APRS weather packet with snow data
|
||||
raw_packet = "SNOW-1>APRS,WIDE1-1:!3216.46N/09647.82W_180/010g015t072r000p000h45b10132s005"
|
||||
|
||||
# Parse the packet using the real APRS parser
|
||||
{:ok, parsed_packet} = Aprs.parse(raw_packet)
|
||||
|
||||
packet_data = %{
|
||||
base_callsign: "SNOW",
|
||||
ssid: "1",
|
||||
sender: "SNOW-1",
|
||||
destination: "APRS",
|
||||
data_type: "position",
|
||||
path: "WIDE1-1",
|
||||
information_field: "!3216.46N/09647.82W_180/010g015t072r000p000h45b10132s005",
|
||||
raw_packet: raw_packet,
|
||||
data_extended: parsed_packet
|
||||
}
|
||||
|
||||
assert {:ok, stored_packet} = Packets.store_packet(packet_data)
|
||||
assert stored_packet.data_type == "weather"
|
||||
assert stored_packet.snow == 5.0
|
||||
refute Map.has_key?(stored_packet, :raw_weather_data)
|
||||
refute Map.has_key?(stored_packet, "raw_weather_data")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -66,6 +66,10 @@ defmodule AprsmeWeb.MapLive.IndexTest do
|
|||
end
|
||||
|
||||
test "loads historical packets when map is ready", %{conn: conn} do
|
||||
# Mock the Packets.get_packets_for_replay function to return empty list
|
||||
# since we now load all historical packets at once instead of replaying
|
||||
Mox.stub(Aprsme.PacketsMock, :get_packets_for_replay, fn _opts -> [] end)
|
||||
|
||||
{:ok, view, _html} = live(conn, "/")
|
||||
|
||||
# Simulate map initialization which should trigger historical packet loading
|
||||
|
|
|
|||
382
test/aprsme_web/live/map_live/packet_utils_test.exs
Normal file
382
test/aprsme_web/live/map_live/packet_utils_test.exs
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
defmodule AprsmeWeb.MapLive.PacketUtilsTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias AprsmeWeb.MapLive.PacketUtils
|
||||
|
||||
describe "weather popup generation" do
|
||||
test "generates weather popup with actual weather data" do
|
||||
packet = %{
|
||||
id: 1,
|
||||
base_callsign: "TEST",
|
||||
sender: "TEST-1",
|
||||
ssid: "1",
|
||||
data_type: "position",
|
||||
symbol_table_id: "/",
|
||||
symbol_code: "_",
|
||||
lat: 32.7767,
|
||||
lon: -96.7970,
|
||||
received_at: DateTime.utc_now(),
|
||||
temperature: 72.0,
|
||||
humidity: 45.0,
|
||||
wind_direction: 180,
|
||||
wind_speed: 10.0,
|
||||
wind_gust: 15.0,
|
||||
pressure: 1013.2,
|
||||
rain_1h: 0.0,
|
||||
rain_24h: 0.1,
|
||||
rain_since_midnight: 0.2
|
||||
}
|
||||
|
||||
result = PacketUtils.build_packet_data(packet, true)
|
||||
|
||||
assert result != nil
|
||||
assert result["popup"] != nil
|
||||
|
||||
popup = result["popup"]
|
||||
|
||||
# Check that it's a weather popup
|
||||
assert popup =~ "Weather Report"
|
||||
assert popup =~ "data-timestamp="
|
||||
|
||||
# Check weather data is present
|
||||
assert popup =~ "Temperature: 72.0°F"
|
||||
assert popup =~ "Humidity: 45.0%"
|
||||
assert popup =~ "Wind: 180° at 10.0 mph, gusts to 15.0 mph"
|
||||
assert popup =~ "Pressure: 1013.2 hPa"
|
||||
assert popup =~ "Rain (1h): 0.0 in."
|
||||
assert popup =~ "Rain (24h): 0.1 in."
|
||||
assert popup =~ "Rain (since midnight): 0.2 in."
|
||||
|
||||
# Check callsign and links
|
||||
assert popup =~ "TEST-1"
|
||||
assert popup =~ "/TEST-1"
|
||||
assert popup =~ "/info/TEST-1"
|
||||
end
|
||||
|
||||
test "generates weather popup with N/A for missing weather data" do
|
||||
packet = %{
|
||||
id: 1,
|
||||
base_callsign: "TEST",
|
||||
sender: "TEST-1",
|
||||
ssid: "1",
|
||||
data_type: "position",
|
||||
symbol_table_id: "/",
|
||||
symbol_code: "_",
|
||||
lat: 32.7767,
|
||||
lon: -96.7970,
|
||||
received_at: DateTime.utc_now()
|
||||
# No weather data fields
|
||||
}
|
||||
|
||||
result = PacketUtils.build_packet_data(packet, true)
|
||||
|
||||
assert result != nil
|
||||
assert result["popup"] != nil
|
||||
|
||||
popup = result["popup"]
|
||||
|
||||
# Check that it's a weather popup
|
||||
assert popup =~ "Weather Report"
|
||||
assert popup =~ "data-timestamp="
|
||||
|
||||
# Check weather data shows N/A
|
||||
assert popup =~ "Temperature: N/A°F"
|
||||
assert popup =~ "Humidity: N/A%"
|
||||
assert popup =~ "Wind: N/A° at N/A mph, gusts to N/A mph"
|
||||
assert popup =~ "Pressure: N/A hPa"
|
||||
assert popup =~ "Rain (1h): N/A in."
|
||||
assert popup =~ "Rain (24h): N/A in."
|
||||
assert popup =~ "Rain (since midnight): N/A in."
|
||||
end
|
||||
|
||||
test "generates weather popup with partial weather data" do
|
||||
packet = %{
|
||||
id: 1,
|
||||
base_callsign: "TEST",
|
||||
sender: "TEST-1",
|
||||
ssid: "1",
|
||||
data_type: "position",
|
||||
symbol_table_id: "/",
|
||||
symbol_code: "_",
|
||||
lat: 32.7767,
|
||||
lon: -96.7970,
|
||||
received_at: DateTime.utc_now(),
|
||||
temperature: 85.0,
|
||||
humidity: 60.0
|
||||
# Missing wind, pressure, rain data
|
||||
}
|
||||
|
||||
result = PacketUtils.build_packet_data(packet, true)
|
||||
|
||||
assert result != nil
|
||||
assert result["popup"] != nil
|
||||
|
||||
popup = result["popup"]
|
||||
|
||||
# Check that it's a weather popup
|
||||
assert popup =~ "Weather Report"
|
||||
assert popup =~ "data-timestamp="
|
||||
|
||||
# Check available weather data
|
||||
assert popup =~ "Temperature: 85.0°F"
|
||||
assert popup =~ "Humidity: 60.0%"
|
||||
|
||||
# Check missing data shows N/A
|
||||
assert popup =~ "Wind: N/A° at N/A mph, gusts to N/A mph"
|
||||
assert popup =~ "Pressure: N/A hPa"
|
||||
assert popup =~ "Rain (1h): N/A in."
|
||||
assert popup =~ "Rain (24h): N/A in."
|
||||
assert popup =~ "Rain (since midnight): N/A in."
|
||||
end
|
||||
|
||||
test "generates standard popup for non-weather packets" do
|
||||
packet = %{
|
||||
id: 1,
|
||||
base_callsign: "TEST",
|
||||
sender: "TEST-1",
|
||||
ssid: "1",
|
||||
data_type: "position",
|
||||
symbol_table_id: "/",
|
||||
symbol_code: ">",
|
||||
lat: 32.7767,
|
||||
lon: -96.7970,
|
||||
received_at: DateTime.utc_now(),
|
||||
comment: "Test comment"
|
||||
}
|
||||
|
||||
result = PacketUtils.build_packet_data(packet, true)
|
||||
|
||||
assert result != nil
|
||||
assert result["popup"] != nil
|
||||
|
||||
popup = result["popup"]
|
||||
|
||||
# Check that it's NOT a weather popup
|
||||
refute popup =~ "Weather Report"
|
||||
refute popup =~ "data-timestamp="
|
||||
|
||||
# Check standard popup content
|
||||
assert popup =~ "TEST-1"
|
||||
assert popup =~ "/TEST-1"
|
||||
assert popup =~ "/info/TEST-1"
|
||||
assert popup =~ "Test comment"
|
||||
end
|
||||
|
||||
test "generates weather popup with timestamp" do
|
||||
received_at = DateTime.utc_now()
|
||||
|
||||
packet = %{
|
||||
id: 1,
|
||||
base_callsign: "TEST",
|
||||
sender: "TEST-1",
|
||||
ssid: "1",
|
||||
data_type: "position",
|
||||
symbol_table_id: "/",
|
||||
symbol_code: "_",
|
||||
lat: 32.7767,
|
||||
lon: -96.7970,
|
||||
received_at: received_at,
|
||||
temperature: 72.0,
|
||||
humidity: 45.0
|
||||
}
|
||||
|
||||
result = PacketUtils.build_packet_data(packet, true)
|
||||
|
||||
assert result != nil
|
||||
assert result["popup"] != nil
|
||||
|
||||
popup = result["popup"]
|
||||
|
||||
# Check timestamp is included
|
||||
assert popup =~ "aprs-timestamp"
|
||||
assert popup =~ Calendar.strftime(received_at, "%Y-%m-%d %H:%M:%S UTC")
|
||||
end
|
||||
|
||||
test "handles weather data from data_extended field" do
|
||||
packet = %{
|
||||
id: 1,
|
||||
base_callsign: "TEST",
|
||||
sender: "TEST-1",
|
||||
ssid: "1",
|
||||
data_type: "position",
|
||||
symbol_table_id: "/",
|
||||
symbol_code: "_",
|
||||
lat: 32.7767,
|
||||
lon: -96.7970,
|
||||
received_at: DateTime.utc_now(),
|
||||
data_extended: %{
|
||||
temperature: 75.0,
|
||||
humidity: 50.0,
|
||||
wind_direction: 270,
|
||||
wind_speed: 12.0,
|
||||
pressure: 1015.0
|
||||
}
|
||||
}
|
||||
|
||||
result = PacketUtils.build_packet_data(packet, true)
|
||||
|
||||
assert result != nil
|
||||
assert result["popup"] != nil
|
||||
|
||||
popup = result["popup"]
|
||||
|
||||
# Check weather data from data_extended
|
||||
assert popup =~ "Temperature: 75.0°F"
|
||||
assert popup =~ "Humidity: 50.0%"
|
||||
assert popup =~ "Wind: 270° at 12.0 mph"
|
||||
assert popup =~ "Pressure: 1015.0 hPa"
|
||||
end
|
||||
|
||||
test "handles weather data with string keys in data_extended" do
|
||||
packet = %{
|
||||
id: 1,
|
||||
base_callsign: "TEST",
|
||||
sender: "TEST-1",
|
||||
ssid: "1",
|
||||
data_type: "position",
|
||||
symbol_table_id: "/",
|
||||
symbol_code: "_",
|
||||
lat: 32.7767,
|
||||
lon: -96.7970,
|
||||
received_at: DateTime.utc_now(),
|
||||
data_extended: %{
|
||||
"temperature" => 80.0,
|
||||
"humidity" => 55.0,
|
||||
"wind_direction" => 90,
|
||||
"wind_speed" => 8.0
|
||||
}
|
||||
}
|
||||
|
||||
result = PacketUtils.build_packet_data(packet, true)
|
||||
|
||||
assert result != nil
|
||||
assert result["popup"] != nil
|
||||
|
||||
popup = result["popup"]
|
||||
|
||||
# Check weather data from data_extended with string keys
|
||||
assert popup =~ "Temperature: 80.0°F"
|
||||
assert popup =~ "Humidity: 55.0%"
|
||||
assert popup =~ "Wind: 90° at 8.0 mph"
|
||||
end
|
||||
end
|
||||
|
||||
describe "weather packet detection" do
|
||||
test "identifies weather packets by symbol" do
|
||||
weather_packet = %{
|
||||
data_type: "position",
|
||||
symbol_table_id: "/",
|
||||
symbol_code: "_"
|
||||
}
|
||||
|
||||
assert PacketUtils.weather_packet?(weather_packet)
|
||||
end
|
||||
|
||||
test "identifies weather packets by data_type" do
|
||||
weather_packet = %{
|
||||
data_type: "weather",
|
||||
symbol_table_id: "/",
|
||||
symbol_code: ">"
|
||||
}
|
||||
|
||||
assert PacketUtils.weather_packet?(weather_packet)
|
||||
end
|
||||
|
||||
test "does not identify non-weather packets" do
|
||||
regular_packet = %{
|
||||
data_type: "position",
|
||||
symbol_table_id: "/",
|
||||
symbol_code: ">"
|
||||
}
|
||||
|
||||
refute PacketUtils.weather_packet?(regular_packet)
|
||||
end
|
||||
end
|
||||
|
||||
describe "weather field extraction" do
|
||||
test "extracts weather fields from packet" do
|
||||
packet = %{
|
||||
temperature: 72.0,
|
||||
humidity: 45.0,
|
||||
wind_direction: 180,
|
||||
wind_speed: 10.0
|
||||
}
|
||||
|
||||
assert PacketUtils.get_weather_field(packet, :temperature) == 72.0
|
||||
assert PacketUtils.get_weather_field(packet, :humidity) == 45.0
|
||||
assert PacketUtils.get_weather_field(packet, :wind_direction) == 180
|
||||
assert PacketUtils.get_weather_field(packet, :wind_speed) == 10.0
|
||||
end
|
||||
|
||||
test "extracts weather fields from data_extended" do
|
||||
packet = %{
|
||||
data_extended: %{
|
||||
temperature: 75.0,
|
||||
humidity: 50.0
|
||||
}
|
||||
}
|
||||
|
||||
assert PacketUtils.get_weather_field(packet, :temperature) == 75.0
|
||||
assert PacketUtils.get_weather_field(packet, :humidity) == 50.0
|
||||
end
|
||||
|
||||
test "returns N/A for missing weather fields" do
|
||||
packet = %{}
|
||||
|
||||
assert PacketUtils.get_weather_field(packet, :temperature) == "N/A"
|
||||
assert PacketUtils.get_weather_field(packet, :humidity) == "N/A"
|
||||
assert PacketUtils.get_weather_field(packet, :wind_direction) == "N/A"
|
||||
end
|
||||
|
||||
test "handles string keys in data_extended" do
|
||||
packet = %{
|
||||
data_extended: %{
|
||||
"temperature" => 80.0,
|
||||
"humidity" => 55.0
|
||||
}
|
||||
}
|
||||
|
||||
assert PacketUtils.get_weather_field(packet, :temperature) == 80.0
|
||||
assert PacketUtils.get_weather_field(packet, :humidity) == 55.0
|
||||
end
|
||||
|
||||
test "handles string keys in packet" do
|
||||
packet = %{
|
||||
"temperature" => 85.0,
|
||||
"humidity" => 60.0
|
||||
}
|
||||
|
||||
assert PacketUtils.get_weather_field(packet, :temperature) == 85.0
|
||||
assert PacketUtils.get_weather_field(packet, :humidity) == 60.0
|
||||
end
|
||||
end
|
||||
|
||||
describe "callsign generation" do
|
||||
test "generates callsign with SSID" do
|
||||
packet = %{
|
||||
base_callsign: "TEST",
|
||||
ssid: "1"
|
||||
}
|
||||
|
||||
assert PacketUtils.generate_callsign(packet) == "TEST-1"
|
||||
end
|
||||
|
||||
test "generates callsign without SSID" do
|
||||
packet = %{
|
||||
base_callsign: "TEST",
|
||||
ssid: nil
|
||||
}
|
||||
|
||||
assert PacketUtils.generate_callsign(packet) == "TEST"
|
||||
end
|
||||
|
||||
test "generates callsign with empty SSID" do
|
||||
packet = %{
|
||||
base_callsign: "TEST",
|
||||
ssid: ""
|
||||
}
|
||||
|
||||
assert PacketUtils.generate_callsign(packet) == "TEST"
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue