diff --git a/.gitignore b/.gitignore index 7e3a710..7588ece 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ k8s/secrets.yaml /.horusec +/vendor + diff --git a/assets/js/map.ts b/assets/js/map.ts index 704f41b..a74d1df 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -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 = `
-
${callsign}
`; +
${callsign} info
`; // Removed symbol info from popup // content += `
${symbolDesc}
`; diff --git a/lib/aprsme/application.ex b/lib/aprsme/application.ex index e6305d3..b4c49a6 100644 --- a/lib/aprsme/application.ex +++ b/lib/aprsme/application.ex @@ -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 diff --git a/lib/aprsme/aprs_is_connection.ex b/lib/aprsme/aprs_is_connection.ex index e732181..c91eaa5 100644 --- a/lib/aprsme/aprs_is_connection.ex +++ b/lib/aprsme/aprs_is_connection.ex @@ -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 diff --git a/lib/aprsme/packet.ex b/lib/aprsme/packet.ex index f80cdc6..b331837 100644 --- a/lib/aprsme/packet.ex +++ b/lib/aprsme/packet.ex @@ -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 diff --git a/lib/aprsme/packet_consumer.ex b/lib/aprsme/packet_consumer.ex index 343b37d..6ac5d9b 100644 --- a/lib/aprsme/packet_consumer.ex +++ b/lib/aprsme/packet_consumer.ex @@ -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 ] diff --git a/lib/aprsme/packets.ex b/lib/aprsme/packets.ex index 72f300a..5827e14 100644 --- a/lib/aprsme/packets.ex +++ b/lib/aprsme/packets.ex @@ -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() diff --git a/lib/aprsme_web/controllers/info_controller.ex b/lib/aprsme_web/controllers/info_controller.ex new file mode 100644 index 0000000..5339246 --- /dev/null +++ b/lib/aprsme_web/controllers/info_controller.ex @@ -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 diff --git a/lib/aprsme_web/live/map_live/callsign_view.ex b/lib/aprsme_web/live/map_live/callsign_view.ex index adddb0b..fbf5619 100644 --- a/lib/aprsme_web/live/map_live/callsign_view.ex +++ b/lib/aprsme_web/live/map_live/callsign_view.ex @@ -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; diff --git a/lib/aprsme_web/live/map_live/index.ex b/lib/aprsme_web/live/map_live/index.ex index 268ad7a..c786d0e 100644 --- a/lib/aprsme_web/live/map_live/index.ex +++ b/lib/aprsme_web/live/map_live/index.ex @@ -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 diff --git a/lib/aprsme_web/live/map_live/packet_utils.ex b/lib/aprsme_web/live/map_live/packet_utils.ex index 511e740..10e90bf 100644 --- a/lib/aprsme_web/live/map_live/packet_utils.ex +++ b/lib/aprsme_web/live/map_live/packet_utils.ex @@ -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 """
- + #{comment_html} #{timestamp_html}
@@ -225,17 +225,23 @@ defmodule AprsmeWeb.MapLive.PacketUtils do "" end + # Add a unique timestamp to prevent caching + cache_buster = System.system_time(:millisecond) + """ - #{sender} - Weather Report - #{timestamp_html} -
- Temperature: #{get_weather_field(packet, :temperature)}°F
- Humidity: #{get_weather_field(packet, :humidity)}%
- Wind: #{get_weather_field(packet, :wind_direction)}° at #{get_weather_field(packet, :wind_speed)} mph, gusts to #{get_weather_field(packet, :wind_gust)} mph
- Pressure: #{get_weather_field(packet, :pressure)} hPa
- Rain (1h): #{get_weather_field(packet, :rain_1h)} in.
- Rain (24h): #{get_weather_field(packet, :rain_24h)} in.
- Rain (since midnight): #{get_weather_field(packet, :rain_since_midnight)} in.
+
+ +
Weather Report
+ #{timestamp_html} +
+ Temperature: #{get_weather_field(packet, :temperature)}°F
+ Humidity: #{get_weather_field(packet, :humidity)}%
+ Wind: #{get_weather_field(packet, :wind_direction)}° at #{get_weather_field(packet, :wind_speed)} mph, gusts to #{get_weather_field(packet, :wind_gust)} mph
+ Pressure: #{get_weather_field(packet, :pressure)} hPa
+ Rain (1h): #{get_weather_field(packet, :rain_1h)} in.
+ Rain (24h): #{get_weather_field(packet, :rain_24h)} in.
+ Rain (since midnight): #{get_weather_field(packet, :rain_since_midnight)} in.
+
""" end diff --git a/mix.exs b/mix.exs index 5b35133..15c9573 100644 --- a/mix.exs +++ b/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 diff --git a/priv/repo/migrations/20250625160414_add_luminosity_to_packets.exs b/priv/repo/migrations/20250625160414_add_luminosity_to_packets.exs new file mode 100644 index 0000000..d6bf034 --- /dev/null +++ b/priv/repo/migrations/20250625160414_add_luminosity_to_packets.exs @@ -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 diff --git a/priv/repo/migrations/20250625163153_add_snow_to_packets.exs b/priv/repo/migrations/20250625163153_add_snow_to_packets.exs new file mode 100644 index 0000000..4c9a997 --- /dev/null +++ b/priv/repo/migrations/20250625163153_add_snow_to_packets.exs @@ -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 diff --git a/test/aprsme/packets_weather_test.exs b/test/aprsme/packets_weather_test.exs new file mode 100644 index 0000000..c464c26 --- /dev/null +++ b/test/aprsme/packets_weather_test.exs @@ -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 diff --git a/test/aprsme_web/live/map_live/index_test.exs b/test/aprsme_web/live/map_live/index_test.exs index 9e18334..1af9243 100644 --- a/test/aprsme_web/live/map_live/index_test.exs +++ b/test/aprsme_web/live/map_live/index_test.exs @@ -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 diff --git a/test/aprsme_web/live/map_live/packet_utils_test.exs b/test/aprsme_web/live/map_live/packet_utils_test.exs new file mode 100644 index 0000000..e06ceba --- /dev/null +++ b/test/aprsme_web/live/map_live/packet_utils_test.exs @@ -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