From feab00ba239765bf35b0beb6e44f192516290ff8 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 21 Jun 2025 09:32:15 -0500 Subject: [PATCH] cleanup --- lib/aprs_web/controllers/page_controller.ex | 5 - .../controllers/page_html/map.html.heex | 52 -- lib/aprs_web/live/map_live/enhanced.ex | 666 ------------------ lib/aprs_web/live/map_live/index.ex | 144 ++-- lib/aprs_web/router.ex | 2 - lib/parser.ex | 9 +- lib/parser/helpers.ex | 2 +- lib/parser/weather.ex | 6 +- 8 files changed, 106 insertions(+), 780 deletions(-) delete mode 100644 lib/aprs_web/controllers/page_html/map.html.heex delete mode 100644 lib/aprs_web/live/map_live/enhanced.ex 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 @@ -
-
-

Legacy APRS Map

-

- This is the legacy JavaScript-based map. - - Try the new LiveView-based map - - or enhanced version. -

-
- - - - - - - - -
-
- - 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 = """ -
- -
#{symbol_description}
- #{if comment == "", do: "", else: "
#{comment}
"} -
#{Float.round(to_float.(lat), 4)}, #{Float.round(to_float.(lon), 4)}
-
#{timestamp}
-
- """ + 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 + """ +
+ + #{if comment == "", do: "", else: ~s(
#{comment}
)} +
#{Float.round(to_float.(lat), 4)}, #{Float.round(to_float.(lon), 4)}
+
#{timestamp}
+
+ """ + 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)