weather fixing

This commit is contained in:
Graham McIntire 2025-06-25 11:40:04 -05:00
parent 3308fb1922
commit f08330b46c
No known key found for this signature in database
17 changed files with 914 additions and 107 deletions

2
.gitignore vendored
View file

@ -40,3 +40,5 @@ k8s/secrets.yaml
/.horusec
/vendor

View file

@ -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>`;

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
]

View file

@ -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()

View 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

View file

@ -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;

View file

@ -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

View file

@ -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
View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View 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