diff --git a/lib/aprs_web/controllers/page_controller.ex b/lib/aprs_web/controllers/page_controller.ex
index 15f76ca..eeb5220 100644
--- a/lib/aprs_web/controllers/page_controller.ex
+++ b/lib/aprs_web/controllers/page_controller.ex
@@ -5,11 +5,6 @@ defmodule AprsWeb.PageController do
render(conn, :home)
end
- def map(conn, _params) do
- # This serves the legacy JavaScript-based map at /old
- render(conn, :map)
- end
-
def packets(conn, _params) do
render(conn, :packets)
end
diff --git a/lib/aprs_web/controllers/page_html/map.html.heex b/lib/aprs_web/controllers/page_html/map.html.heex
deleted file mode 100644
index 936df81..0000000
--- a/lib/aprs_web/controllers/page_html/map.html.heex
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
diff --git a/lib/aprs_web/live/map_live/enhanced.ex b/lib/aprs_web/live/map_live/enhanced.ex
deleted file mode 100644
index aa80f2a..0000000
--- a/lib/aprs_web/live/map_live/enhanced.ex
+++ /dev/null
@@ -1,666 +0,0 @@
-defmodule AprsWeb.MapLive.Enhanced do
- @moduledoc false
- use AprsWeb, :live_view
-
- import Ecto.Query
- import Phoenix.Component, except: [update: 3]
-
- alias Aprs.Packet
- alias Aprs.Repo
-
- # Default map settings
- @default_center %{lat: 39.8283, lng: -98.5795}
- @default_zoom 4
- @max_markers 500
- # 30 seconds
- @cleanup_interval 30_000
- @packet_retention_minutes 60
-
- def mount(_params, _session, socket) do
- # Schedule periodic cleanup
- if connected?(socket) do
- Process.send_after(self(), :cleanup_old_markers, @cleanup_interval)
- end
-
- socket =
- assign(socket,
- # Map state
- map_center: @default_center,
- map_zoom: @default_zoom,
- map_bounds: default_bounds(),
-
- # Marker management
- active_markers: %{},
- historical_markers: %{},
- marker_count: 0,
-
- # UI state
- page_title: "APRS Map - Enhanced",
- loading: false,
- geolocation_error: nil,
-
- # Replay functionality
- replay_active: false,
- replay_speed: 1000,
- replay_paused: false,
- replay_timer: nil,
- replay_packets: [],
- replay_index: 0,
-
- # Trail visualization
- show_trails: true
- )
-
- {:ok, socket}
- end
-
- def handle_event("map_ready", _params, socket) do
- # Map is ready, load initial markers
- {:noreply, load_markers_in_bounds(socket)}
- end
-
- def handle_event("bounds_changed", params, socket) do
- %{
- "bounds" => bounds,
- "center" => center,
- "zoom" => zoom
- } = params
-
- # Normalize center to use atom keys
- normalized_center = %{
- lat: center["lat"],
- lng: center["lng"]
- }
-
- # Remove out-of-bounds active markers
- new_active_markers =
- socket.assigns.active_markers
- |> Enum.filter(fn {_k, %{packet: packet}} -> within_bounds?(packet, bounds) end)
- |> Map.new()
-
- markers_to_remove =
- socket.assigns.active_markers
- |> Enum.reject(fn {_k, %{packet: packet}} -> within_bounds?(packet, bounds) end)
- |> Enum.map(fn {k, _} -> k end)
-
- socket =
- if markers_to_remove == [] do
- socket
- else
- Enum.reduce(markers_to_remove, socket, fn k, acc ->
- push_event(acc, "remove_marker", %{id: k})
- end)
- end
-
- socket =
- socket
- |> assign(:map_bounds, bounds)
- |> assign(:map_center, normalized_center)
- |> assign(:map_zoom, zoom)
- |> assign(:active_markers, new_active_markers)
- |> load_markers_in_bounds()
-
- {:noreply, socket}
- end
-
- def handle_event("marker_clicked", _params, socket) do
- # You could add marker click logic here
- # For example, show detailed info, center map, etc.
-
- {:noreply, socket}
- end
-
- def handle_event("locate_me", _params, socket) do
- # Request geolocation from client
- {:noreply, push_event(socket, "request_geolocation", %{})}
- end
-
- def handle_event("set_location", %{"lat" => lat, "lng" => lng}, socket) do
- center = %{lat: lat, lng: lng}
- zoom = 12
-
- socket =
- socket
- |> assign(:map_center, center)
- |> assign(:map_zoom, zoom)
- |> assign(:geolocation_error, nil)
-
- # Tell client to zoom to location
- socket =
- push_event(socket, "zoom_to_location", %{
- lat: lat,
- lng: lng,
- zoom: zoom
- })
-
- {:noreply, socket}
- end
-
- def handle_event("geolocation_error", %{"error" => error}, socket) do
- {:noreply, assign(socket, :geolocation_error, error)}
- end
-
- def handle_event("toggle_replay", _params, socket) do
- if socket.assigns.replay_active do
- stop_replay(socket)
- else
- start_replay(socket)
- end
- end
-
- def handle_event("pause_replay", _params, socket) do
- socket =
- if socket.assigns.replay_timer do
- Process.cancel_timer(socket.assigns.replay_timer)
- assign(socket, replay_timer: nil, replay_paused: true)
- else
- socket
- end
-
- {:noreply, socket}
- end
-
- def handle_event("adjust_replay_speed", %{"speed" => speed}, socket) do
- speed_ms = String.to_integer(speed)
- {:noreply, assign(socket, :replay_speed, speed_ms)}
- end
-
- def handle_event("toggle_trails", _params, socket) do
- show_trails = !Map.get(socket.assigns, :show_trails, true)
- socket = assign(socket, :show_trails, show_trails)
- socket = push_event(socket, "toggle_trails", %{show: show_trails})
- {:noreply, socket}
- end
-
- def handle_info(:cleanup_old_markers, socket) do
- socket = cleanup_old_markers(socket)
-
- # Schedule next cleanup
- Process.send_after(self(), :cleanup_old_markers, @cleanup_interval)
-
- {:noreply, socket}
- end
-
- def handle_info(:replay_next, socket) do
- socket = process_next_replay_packet(socket)
- {:noreply, socket}
- end
-
- def handle_info({:new_packet, packet}, socket) do
- id = packet_to_marker_data(packet).id
-
- if has_position_data?(packet) and Map.has_key?(socket.assigns.active_markers, id) and
- not within_bounds?(packet, socket.assigns.map_bounds) do
- socket = push_event(socket, "remove_marker", %{id: id})
- new_active_markers = Map.delete(socket.assigns.active_markers, id)
- {:noreply, assign(socket, :active_markers, new_active_markers)}
- else
- # Only add marker if it is within bounds
- if has_position_data?(packet) and within_bounds?(packet, socket.assigns.map_bounds) do
- socket = add_packet_marker(socket, packet, false)
- {:noreply, socket}
- else
- {:noreply, socket}
- end
- end
- end
-
- def render(assigns) do
- ~H"""
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- <%= if @replay_active do %>
-
- <% end %>
-
-
-
-
-
Active Markers: {map_size(@active_markers)}
-
Historical: {map_size(@historical_markers)}
-
Zoom: {@map_zoom}
-
- Center: {Float.round(@map_center.lat, 4)}, {Float.round(@map_center.lng, 4)}
-
-
- <%= if @geolocation_error do %>
-
- Location Error: {@geolocation_error}
-
- <% end %>
-
- <%= if @loading do %>
-
Loading markers...
- <% end %>
-
-
-
- <%= if @replay_active do %>
-
-
-
-
- Progress: {@replay_index}/{length(@replay_packets)}
-
- <% end %>
- """
- end
-
- # Private helper functions
-
- defp default_bounds do
- %{
- north: 49.0,
- south: 24.0,
- east: -66.0,
- west: -125.0
- }
- end
-
- defp load_markers_in_bounds(socket) do
- bounds = socket.assigns.map_bounds
-
- # Don't load if we don't have proper bounds yet
- if bounds == default_bounds() do
- socket
- else
- socket = assign(socket, :loading, true)
-
- # Fetch recent packets within bounds
- packets = fetch_packets_in_bounds(bounds, @max_markers)
-
- # Convert packets to marker data and send to client
- markers = Enum.map(packets, &packet_to_marker_data/1)
-
- # Clear existing markers and add new ones
- socket =
- socket
- |> push_event("clear_markers", %{})
- |> push_event("add_markers", %{markers: markers})
- |> update_active_markers(packets)
- |> assign(:loading, false)
-
- socket
- end
- end
-
- defp fetch_packets_in_bounds(bounds, limit) do
- cutoff_time = DateTime.add(DateTime.utc_now(), -@packet_retention_minutes * 60, :second)
-
- # Create a bounding box polygon for PostGIS spatial query
- bbox_wkt =
- "POLYGON((#{bounds["west"]} #{bounds["south"]}, #{bounds["east"]} #{bounds["south"]}, #{bounds["east"]} #{bounds["north"]}, #{bounds["west"]} #{bounds["north"]}, #{bounds["west"]} #{bounds["south"]}))"
-
- Repo.all(
- from(p in Packet,
- where: p.has_position == true,
- where: p.received_at >= ^cutoff_time,
- where: not is_nil(p.location),
- where: fragment("ST_Within(?, ST_GeomFromText(?, 4326))", p.location, ^bbox_wkt),
- order_by: [desc: p.received_at],
- limit: ^limit,
- select: %{p | lat: fragment("ST_Y(?)", p.location), lon: fragment("ST_X(?)", p.location)}
- )
- )
- end
-
- defp packet_to_marker_data(packet) do
- data_extended = packet.data_extended || %{}
- callsign = packet.base_callsign <> if packet.ssid, do: "-#{packet.ssid}", else: ""
-
- symbol_table_id =
- Map.get(data_extended, :symbol_table_id) || Map.get(data_extended, "symbol_table_id") || "/"
-
- symbol_code =
- Map.get(data_extended, :symbol_code) || Map.get(data_extended, "symbol_code") || ">"
-
- %{
- id: callsign,
- callsign: callsign,
- lat: packet.lat,
- lng: packet.lon,
- symbol_table: symbol_table_id,
- symbol_code: symbol_code,
- historical: false,
- popup: build_popup_content(packet, callsign, false),
- timestamp: DateTime.to_unix(packet.created_at, :millisecond)
- }
- end
-
- defp build_popup_content(packet, callsign, historical) do
- data_extended = packet.data_extended || %{}
-
- timestamp =
- if historical do
- DateTime.to_string(packet.received_at)
- else
- packet.received_at |> DateTime.to_time() |> Time.to_string()
- end
-
- """
-
-
#{callsign} #{if historical, do: "(Historical)", else: ""}
-
- Position: #{Float.round(packet.lat, 4)}°, #{Float.round(packet.lon, 4)}°
- Type: #{packet.data_type}
- #{if data_extended["comment"], do: "Comment: #{data_extended["comment"]}
", else: ""}
- Path: #{packet.path}
- Time: #{timestamp}
-
-
- """
- end
-
- defp update_active_markers(socket, packets) do
- active_markers =
- Map.new(packets, fn packet ->
- callsign = packet.base_callsign <> if packet.ssid, do: "-#{packet.ssid}", else: ""
- {callsign, %{packet: packet, added_at: DateTime.utc_now()}}
- end)
-
- assign(socket, :active_markers, active_markers)
- end
-
- defp add_packet_marker(socket, packet, historical) do
- if has_position_data?(packet) do
- marker_data = packet_to_marker_data(packet)
- marker_data = Map.put(marker_data, :historical, historical)
-
- socket = push_event(socket, "add_marker", marker_data)
-
- # Update marker tracking
- callsign = marker_data.id
- marker_info = %{packet: packet, added_at: DateTime.utc_now()}
-
- if historical do
- Phoenix.Component.update(socket, :historical_markers, &Map.put(&1, callsign, marker_info))
- else
- Phoenix.Component.update(socket, :active_markers, &Map.put(&1, callsign, marker_info))
- end
- else
- socket
- end
- end
-
- defp cleanup_old_markers(socket) do
- cutoff_time = DateTime.add(DateTime.utc_now(), -@packet_retention_minutes * 60, :second)
-
- # Find old markers
- old_markers =
- socket.assigns.active_markers
- |> Enum.filter(fn {_callsign, %{added_at: added_at}} ->
- DateTime.before?(added_at, cutoff_time)
- end)
- |> Enum.map(fn {callsign, _} -> callsign end)
-
- # Only update the client if the marker is present
- socket =
- if old_markers == [] do
- socket
- else
- Enum.reduce(old_markers, socket, fn callsign, acc_socket ->
- push_event(acc_socket, "remove_marker", %{id: callsign})
- end)
- end
-
- # Use Map.drop/2 for better performance
- active_markers = Map.drop(socket.assigns.active_markers, old_markers)
- assign(socket, :active_markers, active_markers)
- end
-
- defp start_replay(socket) do
- # Fetch historical packets for replay
- bounds = socket.assigns.map_bounds
- one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
- two_hours_ago = DateTime.add(DateTime.utc_now(), -7200, :second)
-
- packets = fetch_historical_packets(bounds, two_hours_ago, one_hour_ago)
-
- socket =
- socket
- |> assign(:replay_active, true)
- |> assign(:replay_packets, packets)
- |> assign(:replay_index, 0)
- |> assign(:replay_paused, false)
-
- # Start replay timer
- timer = Process.send_after(self(), :replay_next, socket.assigns.replay_speed)
- assign(socket, :replay_timer, timer)
- end
-
- defp stop_replay(socket) do
- # Cancel timer if running
- if socket.assigns.replay_timer do
- Process.cancel_timer(socket.assigns.replay_timer)
- end
-
- # Clear historical markers
- socket = push_event(socket, "clear_markers", %{})
-
- socket
- |> assign(:replay_active, false)
- |> assign(:replay_timer, nil)
- |> assign(:replay_packets, [])
- |> assign(:replay_index, 0)
- |> assign(:historical_markers, %{})
- # Reload current markers
- |> load_markers_in_bounds()
- end
-
- defp process_next_replay_packet(socket) do
- packets = socket.assigns.replay_packets
- index = socket.assigns.replay_index
-
- if index < length(packets) do
- packet = Enum.at(packets, index)
- socket = add_packet_marker(socket, packet, true)
-
- # Schedule next packet
- timer = Process.send_after(self(), :replay_next, socket.assigns.replay_speed)
-
- socket
- |> assign(:replay_index, index + 1)
- |> assign(:replay_timer, timer)
- else
- # Replay finished
- assign(socket, :replay_timer, nil)
- end
- end
-
- defp fetch_historical_packets(bounds, start_time, end_time) do
- # Create a bounding box polygon for PostGIS spatial query
- bbox_wkt =
- "POLYGON((#{bounds["west"]} #{bounds["south"]}, #{bounds["east"]} #{bounds["south"]}, #{bounds["east"]} #{bounds["north"]}, #{bounds["west"]} #{bounds["north"]}, #{bounds["west"]} #{bounds["south"]}))"
-
- Repo.all(
- from(p in Packet,
- where: p.has_position == true,
- where: p.received_at >= ^start_time,
- where: p.received_at <= ^end_time,
- where: not is_nil(p.location),
- where: fragment("ST_Within(?, ST_GeomFromText(?, 4326))", p.location, ^bbox_wkt),
- order_by: [asc: p.received_at],
- limit: 1000,
- select: %{p | lat: fragment("ST_Y(?)", p.location), lon: fragment("ST_X(?)", p.location)}
- )
- )
- end
-
- defp has_position_data?(packet) do
- lat = Map.get(packet, :lat) || Map.get(packet, "lat")
- lon = Map.get(packet, :lon) || Map.get(packet, "lon")
- not is_nil(lat) and not is_nil(lon)
- end
-
- defp within_bounds?(packet, bounds) do
- lat = packet.lat
- lng = packet.lon
-
- lat >= bounds["south"] and lat <= bounds["north"] and
- lng >= bounds["west"] and lng <= bounds["east"]
- end
-end
diff --git a/lib/aprs_web/live/map_live/index.ex b/lib/aprs_web/live/map_live/index.ex
index e3af070..bf829f7 100644
--- a/lib/aprs_web/live/map_live/index.ex
+++ b/lib/aprs_web/live/map_live/index.ex
@@ -12,7 +12,6 @@ defmodule AprsWeb.MapLive.Index do
@default_center %{lat: 39.8283, lng: -98.5795}
@default_zoom 5
- @ip_api_url "https://ip-api.com/json/"
@finch_name Aprs.Finch
@impl true
@@ -688,41 +687,46 @@ defmodule AprsWeb.MapLive.Index do
# Helper function to start historical replay
@spec start_historical_replay(Socket.t()) :: Socket.t()
defp start_historical_replay(socket) do
- # Get time range for historical data
- now = DateTime.utc_now()
- one_hour_ago = DateTime.add(now, -60 * 60, :second)
+ # Only fetch historical packets if map is ready and not already replaying
+ if socket.assigns.map_ready and not socket.assigns.replay_active do
+ # Get time range for historical data
+ now = DateTime.utc_now()
+ one_hour_ago = DateTime.add(now, -60 * 60, :second)
- # Convert map bounds to the format expected by the database query
- bounds = [
- socket.assigns.map_bounds.west,
- socket.assigns.map_bounds.south,
- socket.assigns.map_bounds.east,
- socket.assigns.map_bounds.north
- ]
+ # Convert map bounds to the format expected by the database query
+ bounds = [
+ socket.assigns.map_bounds.west,
+ socket.assigns.map_bounds.south,
+ socket.assigns.map_bounds.east,
+ socket.assigns.map_bounds.north
+ ]
- # Fetch historical packets with position data within the current map bounds
- historical_packets = fetch_historical_packets(bounds, one_hour_ago, now)
+ # Fetch historical packets with position data within the current map bounds
+ historical_packets = fetch_historical_packets(bounds, one_hour_ago, now)
- if Enum.empty?(historical_packets) do
- # No historical packets found - silently continue without replay
- socket
+ if Enum.empty?(historical_packets) do
+ # No historical packets found - silently continue without replay
+ socket
+ else
+ # Clear any previous historical packets from the map
+ socket = push_event(socket, "clear_historical_packets", %{})
+
+ # Start replay
+ timer_ref = Process.send_after(self(), :replay_next_packet, 1000)
+
+ assign(socket,
+ replay_active: true,
+ replay_packets: historical_packets,
+ replay_index: 0,
+ replay_timer_ref: timer_ref,
+ replay_start_time: one_hour_ago,
+ replay_end_time: now,
+ historical_packets: %{},
+ replay_started: true
+ )
+ end
else
- # Clear any previous historical packets from the map
- socket = push_event(socket, "clear_historical_packets", %{})
-
- # Start replay
- timer_ref = Process.send_after(self(), :replay_next_packet, 1000)
-
- assign(socket,
- replay_active: true,
- replay_packets: historical_packets,
- replay_index: 0,
- replay_timer_ref: timer_ref,
- replay_start_time: one_hour_ago,
- replay_end_time: now,
- historical_packets: %{},
- replay_started: true
- )
+ socket
end
end
@@ -783,6 +787,7 @@ defmodule AprsWeb.MapLive.Index do
defp build_packet_data(packet) do
{lat, lon, data_extended} = MapHelpers.get_coordinates(packet)
callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))
+
# Only include packets with valid position data and a non-empty callsign
if lat && lon && callsign != "" && callsign != nil do
build_packet_map(packet, lat, lon, data_extended)
@@ -846,15 +851,23 @@ defmodule AprsWeb.MapLive.Index do
0.0
end
- popup = """
-
- """
+ is_weather_packet =
+ (Map.get(packet, :data_type) || Map.get(packet, "data_type")) == "weather" or
+ (symbol_table_id == "/" and symbol_code == "_")
+
+ popup =
+ if is_weather_packet do
+ build_weather_popup_html(packet, callsign)
+ else
+ """
+
+ """
+ end
%{
"id" => callsign,
@@ -875,6 +888,42 @@ defmodule AprsWeb.MapLive.Index do
}
end
+ defp build_weather_popup_html(packet, callsign) do
+ received_at =
+ cond do
+ Map.has_key?(packet, :received_at) -> packet.received_at
+ Map.has_key?(packet, "received_at") -> packet["received_at"]
+ true -> nil
+ end
+
+ timestamp_str =
+ if received_at,
+ do: Calendar.strftime(received_at, "%Y-%m-%d %H:%M:%S"),
+ else: "N/A"
+
+ """
+ #{callsign} - Weather Report
+ #{timestamp_str} UTC
+
+ 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
+
+ defp get_weather_field(packet, key) do
+ data_extended = Map.get(packet, "data_extended", %{})
+
+ Map.get(packet, key) ||
+ Map.get(packet, to_string(key)) ||
+ Map.get(data_extended, key) ||
+ Map.get(data_extended, to_string(key)) || "N/A"
+ end
+
@spec generate_callsign(map() | struct()) :: String.t()
defp generate_callsign(packet) do
base_callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))
@@ -894,17 +943,8 @@ defmodule AprsWeb.MapLive.Index do
defp get_ip_location(nil), do: nil
defp get_ip_location(ip) do
- url = "#{@ip_api_url}#{ip}"
-
- request =
- Finch.build(:get, url, [
- {"User-Agent", "APRS.me/1.0"},
- {"Accept", "application/json"}
- ])
-
- Process.sleep(2000)
-
- case Finch.request(request, @finch_name, receive_timeout: 10_000) do
+ # Asynchronously fetch IP location
+ case :get |> Finch.build("http://ip-api.com/json/#{ip}") |> Finch.request(@finch_name) do
{:ok, %{status: 200, body: body}} -> handle_ip_api_response(body)
{:ok, _response} -> send_default_ip_location()
{:error, %{reason: :timeout}} -> send_default_ip_location()
diff --git a/lib/aprs_web/router.ex b/lib/aprs_web/router.ex
index c266b48..eba5664 100644
--- a/lib/aprs_web/router.ex
+++ b/lib/aprs_web/router.ex
@@ -30,8 +30,6 @@ defmodule AprsWeb.Router do
live_dashboard "/dashboard", metrics: AprsWeb.Telemetry
live "/", MapLive.Index, :index
- live "/enhanced", MapLive.Enhanced, :index
- get "/old", PageController, :map
live "/status", StatusLive.Index, :index
live "/packets", PacketsLive.Index, :index
diff --git a/lib/parser.ex b/lib/parser.ex
index e8d9b68..b17e25a 100644
--- a/lib/parser.ex
+++ b/lib/parser.ex
@@ -458,7 +458,7 @@ defmodule Parser do
(is_number(lat) or is_struct(lat, Decimal)) and
(is_number(lon) or is_struct(lon, Decimal))
- %{
+ base_map = %{
latitude: lat,
longitude: lon,
timestamp: nil,
@@ -475,6 +475,13 @@ defmodule Parser do
has_position: has_position
}
+ if sym_table_id == "/" and symbol_code == "_" do
+ weather_map = Parser.Weather.parse_weather_data(comment)
+ Map.merge(base_map, weather_map)
+ else
+ base_map
+ end
+
<> ->
%{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude)
ambiguity = Parser.Helpers.calculate_position_ambiguity(latitude, longitude)
diff --git a/lib/parser/helpers.ex b/lib/parser/helpers.ex
index 31933dc..72d14f6 100644
--- a/lib/parser/helpers.ex
+++ b/lib/parser/helpers.ex
@@ -267,7 +267,7 @@ defmodule Parser.Helpers do
@spec parse_wind_speed(String.t()) :: integer() | nil
def parse_wind_speed(weather_data) do
- case Regex.run(~r'/(\d{3})', weather_data) do
+ case Regex.run(~r/\/(\d{3})/, weather_data) do
[_, speed] -> String.to_integer(speed)
nil -> nil
end
diff --git a/lib/parser/weather.ex b/lib/parser/weather.ex
index f197822..d208591 100644
--- a/lib/parser/weather.ex
+++ b/lib/parser/weather.ex
@@ -17,7 +17,11 @@ defmodule Parser.Weather do
Map.merge(%{data_type: :weather}, weather_data)
end
- defp parse_weather_data(weather_data) do
+ @doc """
+ Parses a weather data string into a map of weather values.
+ """
+ @spec parse_weather_data(String.t()) :: map()
+ def parse_weather_data(weather_data) do
timestamp = Parser.Helpers.extract_timestamp(weather_data)
weather_data = Parser.Helpers.remove_timestamp(weather_data)