diff --git a/lib/aprs/packets.ex b/lib/aprs/packets.ex index 56eb43c..d88e611 100644 --- a/lib/aprs/packets.ex +++ b/lib/aprs/packets.ex @@ -24,126 +24,13 @@ defmodule Aprs.Packets do require Logger try do - # Convert to map if it's a struct, or use as is if already a map - packet_attrs = - case packet_data do - %Packet{} = packet -> - packet - |> Map.from_struct() - |> Map.delete(:__meta__) - - %{} -> - packet_data - end - - # Sanitize all string fields to prevent UTF-8 encoding errors - packet_attrs = sanitize_packet_strings(packet_attrs) - - # Convert data_type to string if it's an atom - packet_attrs = - case packet_attrs do - %{data_type: data_type} when is_atom(data_type) -> - Map.put(packet_attrs, :data_type, to_string(data_type)) - - _ -> - packet_attrs - end - - # Make sure received_at is set with explicit UTC DateTime - current_time = DateTime.truncate(DateTime.utc_now(), :microsecond) - packet_attrs = Map.put(packet_attrs, :received_at, current_time) - - # Patch: If data_extended has latitude/longitude, set them as lat/lon as Decimal - packet_attrs = - case packet_attrs[:data_extended] do - %{} = ext -> - # Convert struct to map if needed - ext_map = if Map.has_key?(ext, :__struct__), do: Map.from_struct(ext), else: ext - - lat = - ext_map[:latitude] || ext_map["latitude"] || - (Map.has_key?(ext_map, :position) && - (ext_map[:position][:latitude] || ext_map[:position]["latitude"])) || - (Map.has_key?(ext_map, "position") && - (ext_map["position"][:latitude] || ext_map["position"]["latitude"])) - - lon = - ext_map[:longitude] || ext_map["longitude"] || - (Map.has_key?(ext_map, :position) && - (ext_map[:position][:longitude] || ext_map[:position]["longitude"])) || - (Map.has_key?(ext_map, "position") && - (ext_map["position"][:longitude] || ext_map["position"]["longitude"])) - - latd = to_decimal(lat) - lond = to_decimal(lon) - - if not is_nil(latd) and not is_nil(lond) do - packet_attrs - |> Map.put(:lat, latd) - |> Map.put(:lon, lond) - else - packet_attrs - end - - _ -> - packet_attrs - end - - # Extract position data with error handling + packet_attrs = normalize_packet_attrs(packet_data) + packet_attrs = set_received_at(packet_attrs) + packet_attrs = patch_lat_lon_from_data_extended(packet_attrs) {lat, lon} = extract_position(packet_attrs) - - # Helper to round to 6 decimal places - round6 = fn - nil -> - nil - - %Decimal{} = d -> - Decimal.round(d, 6) - - n when is_float(n) -> - Float.round(n, 6) - - n when is_integer(n) -> - n * 1.0 - - n when is_binary(n) -> - case Float.parse(n) do - {f, _} -> Float.round(f, 6) - :error -> nil - end - - _ -> - nil - end - - # Set position fields if found (this will overwrite above if extract_position finds something different) - packet_attrs = - packet_attrs - |> Map.put(:lat, round6.(lat)) - |> Map.put(:lon, round6.(lon)) - - # Normalize ssid to string if present and not nil - packet_attrs = - case Map.get(packet_attrs, :ssid) do - nil -> packet_attrs - ssid -> Map.put(packet_attrs, :ssid, to_string(ssid)) - end - - # Insert the packet - case %Packet{} - |> Packet.changeset(packet_attrs) - |> Repo.insert() do - {:ok, packet} -> - {:ok, packet} - - {:error, changeset} -> - # Store validation errors as bad packets - error_message = - Enum.map_join(changeset.errors, ", ", fn {field, {msg, _}} -> "#{field}: #{msg}" end) - - store_bad_packet(packet_data, %{message: error_message, type: "ValidationError"}) - {:error, :validation_error} - end + packet_attrs = set_lat_lon(packet_attrs, lat, lon) + packet_attrs = normalize_ssid(packet_attrs) + insert_packet(packet_attrs, packet_data) rescue error -> Logger.error("Exception in store_packet for #{inspect(packet_data[:sender])}: #{inspect(error)}") @@ -153,6 +40,126 @@ defmodule Aprs.Packets do end end + defp normalize_packet_attrs(packet_data) do + case_result = + case packet_data do + %Packet{} = packet -> + packet + |> Map.from_struct() + |> Map.delete(:__meta__) + + %{} -> + packet_data + end + + case_result + |> sanitize_packet_strings() + |> normalize_data_type() + end + + defp normalize_data_type(attrs) do + case attrs do + %{data_type: data_type} when is_atom(data_type) -> + Map.put(attrs, :data_type, to_string(data_type)) + + _ -> + attrs + end + end + + defp set_received_at(attrs) do + current_time = DateTime.truncate(DateTime.utc_now(), :microsecond) + Map.put(attrs, :received_at, current_time) + end + + defp patch_lat_lon_from_data_extended(attrs) do + case attrs[:data_extended] do + %{} = ext -> + ext_map = if Map.has_key?(ext, :__struct__), do: Map.from_struct(ext), else: ext + lat = extract_lat_from_ext_map(ext_map) + lon = extract_lon_from_ext_map(ext_map) + latd = to_decimal(lat) + lond = to_decimal(lon) + + if not is_nil(latd) and not is_nil(lond) do + attrs + |> Map.put(:lat, latd) + |> Map.put(:lon, lond) + else + attrs + end + + _ -> + attrs + end + end + + defp extract_lat_from_ext_map(ext_map) do + ext_map[:latitude] || ext_map["latitude"] || + (Map.has_key?(ext_map, :position) && + (ext_map[:position][:latitude] || ext_map[:position]["latitude"])) || + (Map.has_key?(ext_map, "position") && + (ext_map["position"][:latitude] || ext_map["position"]["latitude"])) + end + + defp extract_lon_from_ext_map(ext_map) do + ext_map[:longitude] || ext_map["longitude"] || + (Map.has_key?(ext_map, :position) && + (ext_map[:position][:longitude] || ext_map[:position]["longitude"])) || + (Map.has_key?(ext_map, "position") && + (ext_map["position"][:longitude] || ext_map["position"]["longitude"])) + end + + defp set_lat_lon(attrs, lat, lon) do + round6 = fn + nil -> + nil + + %Decimal{} = d -> + Decimal.round(d, 6) + + n when is_float(n) -> + Float.round(n, 6) + + n when is_integer(n) -> + n * 1.0 + + n when is_binary(n) -> + case Float.parse(n) do + {f, _} -> Float.round(f, 6) + :error -> nil + end + + _ -> + nil + end + + attrs + |> Map.put(:lat, round6.(lat)) + |> Map.put(:lon, round6.(lon)) + end + + defp normalize_ssid(attrs) do + case Map.get(attrs, :ssid) do + nil -> attrs + ssid -> Map.put(attrs, :ssid, to_string(ssid)) + end + end + + defp insert_packet(attrs, packet_data) do + case %Packet{} |> Packet.changeset(attrs) |> Repo.insert() do + {:ok, packet} -> + {:ok, packet} + + {:error, changeset} -> + error_message = + Enum.map_join(changeset.errors, ", ", fn {field, {msg, _}} -> "#{field}: #{msg}" end) + + store_bad_packet(packet_data, %{message: error_message, type: "ValidationError"}) + {:error, :validation_error} + end + end + @doc """ Stores a bad packet in the database. @@ -200,46 +207,58 @@ defmodule Aprs.Packets do defp extract_position_from_data_extended(nil), do: {nil, nil} defp extract_position_from_data_extended(data_extended) when is_map(data_extended) do - # Standard position format - if not is_nil(data_extended[:latitude]) and not is_nil(data_extended[:longitude]) do - {to_float(data_extended[:latitude]), to_float(data_extended[:longitude])} - # MicE packet format with components + if has_standard_position?(data_extended) do + extract_standard_position(data_extended) else - case data_extended do - %{__struct__: MicE} = mic_e -> - lat = mic_e[:latitude] - lon = mic_e[:longitude] - - if is_number(lat) and is_number(lon) do - {lat, lon} - else - {nil, nil} - end - - _ -> - extract_position_from_mic_e(data_extended) - end + extract_position_from_data_extended_case(data_extended) end end defp extract_position_from_data_extended(_), do: {nil, nil} - defp extract_position_from_mic_e(%{__struct__: MicE} = mic_e) do - if is_number(mic_e.lat_degrees) and is_number(mic_e.lat_minutes) and - is_number(mic_e.lon_degrees) and is_number(mic_e.lon_minutes) do - lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0 - lat = if mic_e.lat_direction == :south, do: -lat, else: lat + defp has_standard_position?(data_extended) do + not is_nil(data_extended[:latitude]) and not is_nil(data_extended[:longitude]) + end - lon = mic_e.lon_degrees + mic_e.lon_minutes / 60.0 - lon = if mic_e.lon_direction == :west, do: -lon, else: lon + defp extract_standard_position(data_extended) do + {to_float(data_extended[:latitude]), to_float(data_extended[:longitude])} + end + defp extract_position_from_data_extended_case(data_extended) do + case data_extended do + %{__struct__: MicE} = mic_e -> + extract_position_from_mic_e_struct(mic_e) + + _ -> + extract_position_from_mic_e(data_extended) + end + end + + defp extract_position_from_mic_e_struct(mic_e) do + lat = mic_e[:latitude] + lon = mic_e[:longitude] + + if is_number(lat) and is_number(lon) do {lat, lon} else {nil, nil} end end - defp extract_position_from_mic_e(_), do: {nil, nil} + defp extract_position_from_mic_e(data_extended) do + if is_number(data_extended[:lat_degrees]) and is_number(data_extended[:lat_minutes]) and + is_number(data_extended[:lon_degrees]) and is_number(data_extended[:lon_minutes]) do + lat = data_extended[:lat_degrees] + data_extended[:lat_minutes] / 60.0 + lat = if data_extended[:lat_direction] == :south, do: -lat, else: lat + + lon = data_extended[:lon_degrees] + data_extended[:lon_minutes] / 60.0 + lon = if data_extended[:lon_direction] == :west, do: -lon, else: lon + + {lat, lon} + else + {nil, nil} + end + end @doc """ Gets packets for replay. diff --git a/lib/aprs_web/live/map_live/index.ex b/lib/aprs_web/live/map_live/index.ex index 5193422..a1f6df2 100644 --- a/lib/aprs_web/live/map_live/index.ex +++ b/lib/aprs_web/live/map_live/index.ex @@ -1049,119 +1049,107 @@ 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 - # Only fetch historical packets if map is ready and bounds are available if socket.assigns.map_ready and socket.assigns.map_bounds do - # Get time range for historical data - now = DateTime.utc_now() - # Use the user's selected historical hours setting - historical_hours = String.to_integer(socket.assigns.historical_hours) - start_time = DateTime.add(now, -historical_hours * 3600, :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 - ] - - # Fetch historical packets with position data within the current map bounds - historical_packets = fetch_historical_packets(bounds, start_time, now) - - if Enum.empty?(historical_packets) do - # No historical packets found - silently continue - socket - else - # Clear any previous historical packets from the map - socket = push_event(socket, "clear_historical_packets", %{}) - - # Group packets by callsign-ssid and process all positions - packet_data_list = - historical_packets - |> Enum.group_by(&generate_callsign/1) - |> Enum.flat_map(fn {callsign, packets} -> - # Sort packets by inserted_at to identify the most recent - # Convert NaiveDateTime to DateTime if needed for proper comparison - sorted_packets = - Enum.sort_by( - packets, - fn packet -> - case packet.inserted_at do - %NaiveDateTime{} = naive_dt -> - DateTime.from_naive!(naive_dt, "Etc/UTC") - - %DateTime{} = dt -> - dt - - _other -> - DateTime.utc_now() - end - end, - {:desc, DateTime} - ) - - # Filter out packets with unchanged positions (only keep if lat/lon changed) - unique_position_packets = filter_unique_positions(sorted_packets) - - # Process all packets with unique positions, marking which is the most recent - unique_position_packets - |> Enum.with_index() - |> Enum.map(fn {packet, index} -> - case build_packet_data(packet) do - nil -> - nil - - packet_data -> - # Generate a unique ID for this historical packet - packet_id = - "hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}_#{index}" - - is_most_recent = index == 0 - - packet_data - |> Map.put("id", packet_id) - |> Map.put("is_historical", true) - |> Map.put("is_most_recent_for_callsign", is_most_recent) - |> Map.put("callsign_group", callsign) - |> Map.put( - "timestamp", - case packet.inserted_at do - %NaiveDateTime{} = naive_dt -> - DateTime.to_unix(DateTime.from_naive!(naive_dt, "Etc/UTC"), :millisecond) - - %DateTime{} = dt -> - DateTime.to_unix(dt, :millisecond) - - _other -> - DateTime.to_unix(DateTime.utc_now(), :millisecond) - end - ) - end - end) - |> Enum.filter(& &1) - end) - - # Send all historical packets at once - socket = push_event(socket, "add_historical_packets", %{packets: packet_data_list}) - - # Store historical packets in assigns for reference - historical_packets_map = - packet_data_list - |> Enum.zip(historical_packets) - |> Enum.reduce(%{}, fn {packet_data, packet}, acc -> - Map.put(acc, packet_data["id"], packet) - end) - - assign(socket, - historical_packets: historical_packets_map, - historical_loaded: true - ) - end + 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", %{}) + + packet_data_list = + historical_packets + |> Enum.group_by(&generate_callsign/1) + |> Enum.flat_map(fn {callsign, packets} -> + sorted_packets = + Enum.sort_by( + packets, + fn packet -> + case packet.inserted_at do + %NaiveDateTime{} = naive_dt -> DateTime.from_naive!(naive_dt, "Etc/UTC") + %DateTime{} = dt -> dt + _other -> DateTime.utc_now() + end + end, + {:desc, DateTime} + ) + + unique_position_packets = filter_unique_positions(sorted_packets) + + unique_position_packets + |> Enum.with_index() + |> Enum.map(fn {packet, index} -> + case build_packet_data(packet) do + nil -> + nil + + packet_data -> + packet_id = + "hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}_#{index}" + + is_most_recent = index == 0 + + packet_data + |> Map.put("id", packet_id) + |> Map.put("is_historical", true) + |> Map.put("is_most_recent_for_callsign", is_most_recent) + |> Map.put("callsign_group", callsign) + |> Map.put( + "timestamp", + case packet.inserted_at do + %NaiveDateTime{} = naive_dt -> + DateTime.to_unix(DateTime.from_naive!(naive_dt, "Etc/UTC"), :millisecond) + + %DateTime{} = dt -> + DateTime.to_unix(dt, :millisecond) + + _other -> + DateTime.to_unix(DateTime.utc_now(), :millisecond) + end + ) + end + end) + |> Enum.filter(& &1) + end) + + socket = push_event(socket, "add_historical_packets", %{packets: packet_data_list}) + + historical_packets_map = + packet_data_list + |> Enum.zip(historical_packets) + |> Enum.reduce(%{}, fn {packet_data, packet}, acc -> + Map.put(acc, packet_data["id"], packet) + end) + + assign(socket, + historical_packets: historical_packets_map, + historical_loaded: true + ) + end + # Filter packets to only include those with unique positions (lat/lon changed) @spec filter_unique_positions([struct()]) :: [struct()] defp filter_unique_positions(packets) do @@ -1223,13 +1211,10 @@ defmodule AprsWeb.MapLive.Index do @spec load_historical_packets_for_bounds(Socket.t(), map()) :: Socket.t() defp load_historical_packets_for_bounds(socket, map_bounds) do - # Get time range for historical data now = DateTime.utc_now() - # Use the user's selected historical hours setting historical_hours = String.to_integer(socket.assigns.historical_hours) start_time = DateTime.add(now, -historical_hours * 3600, :second) - # Convert map bounds to the format expected by the database query bounds = [ map_bounds.west, map_bounds.south, @@ -1237,91 +1222,81 @@ defmodule AprsWeb.MapLive.Index do map_bounds.north ] - # Fetch historical packets with position data within the current map bounds historical_packets = fetch_historical_packets(bounds, start_time, now) if Enum.empty?(historical_packets) do - # No historical packets found socket else - # Group packets by callsign-ssid and process all positions - packet_data_list = - historical_packets - |> Enum.group_by(&generate_callsign/1) - |> Enum.flat_map(fn {callsign, packets} -> - # Sort packets by inserted_at to identify the most recent - sorted_packets = - Enum.sort_by( - packets, - fn packet -> + process_historical_packets_for_bounds(socket, historical_packets) + end + end + + defp process_historical_packets_for_bounds(socket, historical_packets) do + packet_data_list = + historical_packets + |> Enum.group_by(&generate_callsign/1) + |> Enum.flat_map(fn {callsign, packets} -> + sorted_packets = + Enum.sort_by( + packets, + fn packet -> + case packet.inserted_at do + %NaiveDateTime{} = naive_dt -> DateTime.from_naive!(naive_dt, "Etc/UTC") + %DateTime{} = dt -> dt + _other -> DateTime.utc_now() + end + end, + {:desc, DateTime} + ) + + unique_position_packets = filter_unique_positions(sorted_packets) + + unique_position_packets + |> Enum.with_index() + |> Enum.map(fn {packet, index} -> + case build_packet_data(packet) do + nil -> + nil + + packet_data -> + packet_id = + "hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}_#{index}" + + is_most_recent = index == 0 + + packet_data + |> Map.put("id", packet_id) + |> Map.put("is_historical", true) + |> Map.put("is_most_recent_for_callsign", is_most_recent) + |> Map.put("callsign_group", callsign) + |> Map.put( + "timestamp", case packet.inserted_at do %NaiveDateTime{} = naive_dt -> - DateTime.from_naive!(naive_dt, "Etc/UTC") + DateTime.to_unix(DateTime.from_naive!(naive_dt, "Etc/UTC"), :millisecond) %DateTime{} = dt -> - dt + DateTime.to_unix(dt, :millisecond) _other -> - DateTime.utc_now() + DateTime.to_unix(DateTime.utc_now(), :millisecond) end - end, - {:desc, DateTime} - ) - - # Filter out packets with unchanged positions - unique_position_packets = filter_unique_positions(sorted_packets) - - # Process all packets with unique positions, marking which is the most recent - unique_position_packets - |> Enum.with_index() - |> Enum.map(fn {packet, index} -> - case build_packet_data(packet) do - nil -> - nil - - packet_data -> - # Generate a unique ID for this historical packet - packet_id = - "hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}_#{index}" - - is_most_recent = index == 0 - - packet_data - |> Map.put("id", packet_id) - |> Map.put("is_historical", true) - |> Map.put("is_most_recent_for_callsign", is_most_recent) - |> Map.put("callsign_group", callsign) - |> Map.put( - "timestamp", - case packet.inserted_at do - %NaiveDateTime{} = naive_dt -> - DateTime.to_unix(DateTime.from_naive!(naive_dt, "Etc/UTC"), :millisecond) - - %DateTime{} = dt -> - DateTime.to_unix(dt, :millisecond) - - _other -> - DateTime.to_unix(DateTime.utc_now(), :millisecond) - end - ) - end - end) - |> Enum.filter(& &1) + ) + end end) + |> Enum.filter(& &1) + end) - # Send all historical packets at once - socket = push_event(socket, "add_historical_packets", %{packets: packet_data_list}) + socket = push_event(socket, "add_historical_packets", %{packets: packet_data_list}) - # Store historical packets in assigns for reference - historical_packets_map = - packet_data_list - |> Enum.zip(historical_packets) - |> Enum.reduce(%{}, fn {packet_data, packet}, acc -> - Map.put(acc, packet_data["id"], packet) - end) + historical_packets_map = + packet_data_list + |> Enum.zip(historical_packets) + |> Enum.reduce(%{}, fn {packet_data, packet}, acc -> + Map.put(acc, packet_data["id"], packet) + end) - assign(socket, historical_packets: historical_packets_map) - end + assign(socket, historical_packets: historical_packets_map) end @spec within_bounds?(map() | struct(), map()) :: boolean() @@ -1377,7 +1352,7 @@ defmodule AprsWeb.MapLive.Index do timestamp: PacketUtils.get_timestamp(packet), comment: PacketUtils.get_packet_field(packet, :comment, ""), safe_data_extended: PacketUtils.convert_tuples_to_strings(data_extended), - is_weather_packet: PacketUtils.is_weather_packet?(packet) + is_weather_packet: PacketUtils.weather_packet?(packet) } end diff --git a/lib/aprs_web/live/map_live/map_helpers.ex b/lib/aprs_web/live/map_live/map_helpers.ex index 29b3bdc..d90e42d 100644 --- a/lib/aprs_web/live/map_live/map_helpers.ex +++ b/lib/aprs_web/live/map_live/map_helpers.ex @@ -52,61 +52,20 @@ defmodule AprsWeb.MapLive.MapHelpers do @spec within_bounds?(map() | tuple(), map()) :: boolean() def within_bounds?(packet_or_coords, bounds) do - # Handle nil bounds if is_nil(bounds) do false else - to_float = fn - n when is_float(n) -> - n - - n when is_integer(n) -> - n * 1.0 - - %Decimal{} = d -> - Decimal.to_float(d) - - n when is_binary(n) -> - case Float.parse(n) do - {f, _} -> f - :error -> 0.0 - end - - _ -> - 0.0 - end - - {lat, lon} = - cond do - is_map(packet_or_coords) and Map.has_key?(packet_or_coords, :lat) and - Map.has_key?(packet_or_coords, :lon) -> - {packet_or_coords.lat, packet_or_coords.lon} - - is_map(packet_or_coords) and Map.has_key?(packet_or_coords, "lat") and - Map.has_key?(packet_or_coords, "lon") -> - {packet_or_coords["lat"], packet_or_coords["lon"]} - - is_map(packet_or_coords) and Map.has_key?(packet_or_coords, :latitude) and - Map.has_key?(packet_or_coords, :longitude) -> - {packet_or_coords.latitude, packet_or_coords.longitude} - - is_tuple(packet_or_coords) and tuple_size(packet_or_coords) == 2 -> - packet_or_coords - - true -> - {nil, nil} - end + {lat, lon} = extract_lat_lon(packet_or_coords) if is_nil(lat) or is_nil(lon) do false else - lat = to_float.(lat) - lon = to_float.(lon) - south = to_float.(bounds.south) - north = to_float.(bounds.north) - west = to_float.(bounds.west) - east = to_float.(bounds.east) - + lat = to_float(lat) + lon = to_float(lon) + south = to_float(bounds.south) + north = to_float(bounds.north) + west = to_float(bounds.west) + east = to_float(bounds.east) lat_in_bounds = lat >= south && lat <= north lng_in_bounds = @@ -120,4 +79,43 @@ defmodule AprsWeb.MapLive.MapHelpers do end end end + + defp extract_lat_lon(packet_or_coords) do + cond do + is_map(packet_or_coords) and Map.has_key?(packet_or_coords, :lat) and + Map.has_key?(packet_or_coords, :lon) -> + extract_lat_lon_atom(packet_or_coords) + + is_map(packet_or_coords) and Map.has_key?(packet_or_coords, "lat") and + Map.has_key?(packet_or_coords, "lon") -> + extract_lat_lon_string(packet_or_coords) + + is_map(packet_or_coords) and Map.has_key?(packet_or_coords, :latitude) and + Map.has_key?(packet_or_coords, :longitude) -> + extract_lat_lon_atom_alt(packet_or_coords) + + is_tuple(packet_or_coords) and tuple_size(packet_or_coords) == 2 -> + packet_or_coords + + true -> + {nil, nil} + end + end + + defp extract_lat_lon_atom(packet), do: {packet.lat, packet.lon} + defp extract_lat_lon_string(packet), do: {packet["lat"], packet["lon"]} + defp extract_lat_lon_atom_alt(packet), do: {packet.latitude, packet.longitude} + + defp to_float(n) when is_float(n), do: n + defp to_float(n) when is_integer(n), do: n * 1.0 + defp to_float(%Decimal{} = d), do: Decimal.to_float(d) + + defp to_float(n) when is_binary(n) do + case Float.parse(n) do + {f, _} -> f + :error -> 0.0 + end + end + + defp to_float(_), do: 0.0 end diff --git a/lib/aprs_web/live/map_live/packet_utils.ex b/lib/aprs_web/live/map_live/packet_utils.ex index 040c472..bee56e7 100644 --- a/lib/aprs_web/live/map_live/packet_utils.ex +++ b/lib/aprs_web/live/map_live/packet_utils.ex @@ -90,8 +90,8 @@ defmodule AprsWeb.MapLive.PacketUtils do @doc """ Determines if a packet is a weather packet. """ - @spec is_weather_packet?(map()) :: boolean() - def is_weather_packet?(packet) do + @spec weather_packet?(map()) :: boolean() + def weather_packet?(packet) do data_type = get_packet_field(packet, :data_type, "") {symbol_table_id, symbol_code} = get_symbol_info(packet) diff --git a/lib/aprs_web/live/user_forgot_password_live.ex b/lib/aprs_web/live/user_forgot_password_live.ex index 5c26e48..7705ee7 100644 --- a/lib/aprs_web/live/user_forgot_password_live.ex +++ b/lib/aprs_web/live/user_forgot_password_live.ex @@ -9,10 +9,8 @@ defmodule AprsWeb.UserForgotPasswordLive do
<.header class="text-center" /> - <.header class="text-center"> - Forgot your password? - <:subtitle>We'll send a password reset link to your inbox - +

Forgot your password?

+

We'll send a password reset link to your inbox

<.simple_form :let={f} id="reset_password_form" for={%{}} as={:user} phx-submit="send_email"> <.input field={{f, :email}} type="email" placeholder="Email" required /> diff --git a/lib/aprs_web/live/user_login_live.ex b/lib/aprs_web/live/user_login_live.ex index 2f4ae18..64d0efa 100644 --- a/lib/aprs_web/live/user_login_live.ex +++ b/lib/aprs_web/live/user_login_live.ex @@ -10,16 +10,14 @@ defmodule AprsWeb.UserLoginLive do
<.header class="text-center" /> - <.header class="text-center"> - Sign in to account - <:subtitle> - Don't have an account? - <.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline"> - Sign up - - for an account now. - - +

Sign in to account

+

+ Don't have an account? + <.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline"> + Sign up + + for an account now. +

<.simple_form :let={f} diff --git a/lib/aprs_web/live/user_registration_live.ex b/lib/aprs_web/live/user_registration_live.ex index 78209ec..3729a39 100644 --- a/lib/aprs_web/live/user_registration_live.ex +++ b/lib/aprs_web/live/user_registration_live.ex @@ -10,16 +10,14 @@ defmodule AprsWeb.UserRegistrationLive do
<.header class="text-center" /> - <.header class="text-center"> - Register for an account - <:subtitle> - Already registered? - <.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline"> - Sign in - - to your account now. - - +

Register for an account

+

+ Already registered? + <.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline"> + Sign in + + to your account now. +

<.simple_form :let={f} diff --git a/lib/aprs_web/live/weather_live/callsign_view.ex b/lib/aprs_web/live/weather_live/callsign_view.ex index acdcb16..bfab6ac 100644 --- a/lib/aprs_web/live/weather_live/callsign_view.ex +++ b/lib/aprs_web/live/weather_live/callsign_view.ex @@ -22,6 +22,6 @@ defmodule AprsWeb.WeatherLive.CallsignView do # Get the most recent packet for this callsign that is a weather report %{callsign: callsign, limit: 10} |> Packets.get_recent_packets() - |> Enum.find(&PacketUtils.is_weather_packet?/1) + |> Enum.find(&PacketUtils.weather_packet?/1) end end diff --git a/lib/parser.ex b/lib/parser.ex index b6923a5..4c8b78d 100644 --- a/lib/parser.ex +++ b/lib/parser.ex @@ -226,7 +226,6 @@ defmodule Parser do end def parse_data(:timestamped_position_with_message, _destination, data) do - # Correct binary pattern match for APRS position with weather case data do <> -> @@ -244,32 +243,15 @@ defmodule Parser do rest ) - Map.put( - result, - :has_location, - (is_number(result[:latitude]) or is_struct(result[:latitude], Decimal)) and - (is_number(result[:longitude]) or is_struct(result[:longitude], Decimal)) - ) + add_has_location(result) else result = parse_position_with_timestamp(true, data, :timestamped_position_with_message) - - Map.put( - result, - :has_location, - (is_number(result[:latitude]) or is_struct(result[:latitude], Decimal)) and - (is_number(result[:longitude]) or is_struct(result[:longitude], Decimal)) - ) + add_has_location(result) end _ -> result = parse_position_with_timestamp(true, data, :timestamped_position_with_message) - - Map.put( - result, - :has_location, - (is_number(result[:latitude]) or is_struct(result[:latitude], Decimal)) and - (is_number(result[:longitude]) or is_struct(result[:longitude], Decimal)) - ) + add_has_location(result) end end @@ -304,7 +286,6 @@ defmodule Parser do def parse_data(:query, _destination, data), do: parse_query(data) def parse_data(:user_defined, _destination, data), do: parse_user_defined(data) def parse_data(:third_party_traffic, _destination, data), do: parse_third_party_traffic(data) - def parse_data(:peet_logging, _destination, data), do: Parser.Helpers.parse_peet_logging(data) def parse_data(:invalid_test_data, _destination, data), do: Parser.Helpers.parse_invalid_test_data(data) @@ -354,9 +335,17 @@ defmodule Parser do end def parse_data(:phg_data, _destination, data), do: parse_phg_data(data) - def parse_data(_type, _destination, _data), do: nil + defp add_has_location(result) do + Map.put( + result, + :has_location, + (is_number(result[:latitude]) or is_struct(result[:latitude], Decimal)) and + (is_number(result[:longitude]) or is_struct(result[:longitude], Decimal)) + ) + end + @spec parse_position_with_datetime_and_weather( boolean(), binary(), @@ -427,131 +416,149 @@ defmodule Parser do case position_data do <> -> - %{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude) - ambiguity = Parser.Helpers.calculate_position_ambiguity(latitude, longitude) - dao_data = parse_dao_extension(comment) - {course, speed} = extract_course_and_speed(comment) - - has_position = - (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, - symbol_table_id: sym_table_id, - symbol_code: symbol_code, - comment: comment, - data_type: :position, - aprs_messaging?: false, - compressed?: false, - position_ambiguity: ambiguity, - dao: dao_data, - course: course, - speed: speed, - 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 + parse_position_uncompressed(latitude, sym_table_id, longitude, symbol_code, comment) <> -> - %{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude) - ambiguity = Parser.Helpers.calculate_position_ambiguity(latitude, longitude) - - has_position = - (is_number(lat) or is_struct(lat, Decimal)) and - (is_number(lon) or is_struct(lon, Decimal)) - - %{ - latitude: lat, - longitude: lon, - timestamp: nil, - symbol_table_id: sym_table_id, - symbol_code: "_", - data_type: :position, - aprs_messaging?: false, - compressed?: false, - position_ambiguity: ambiguity, - dao: nil, - course: nil, - speed: nil, - has_position: has_position - } + parse_position_short_uncompressed(latitude, sym_table_id, longitude) <<"/", latitude_compressed::binary-size(4), longitude_compressed::binary-size(4), symbol_code::binary-size(1), cs::binary-size(2), compression_type::binary-size(1), comment::binary>> -> - try do - converted_lat = Parser.Helpers.convert_compressed_lat(latitude_compressed) - converted_lon = Parser.Helpers.convert_compressed_lon(longitude_compressed) - compressed_cs = Parser.Helpers.convert_compressed_cs(cs) - ambiguity = Parser.Helpers.calculate_compressed_ambiguity(compression_type) - - has_position = - (is_number(converted_lat) or is_struct(converted_lat, Decimal)) and - (is_number(converted_lon) or is_struct(converted_lon, Decimal)) - - base_data = %{ - latitude: converted_lat, - longitude: converted_lon, - symbol_table_id: "/", - symbol_code: symbol_code, - comment: comment, - position_format: :compressed, - compression_type: compression_type, - data_type: :position, - compressed?: true, - position_ambiguity: ambiguity, - dao: nil, - has_position: has_position - } - - # Merge in course/speed from compressed_cs - Map.merge(base_data, compressed_cs) - rescue - _e -> - %{ - latitude: nil, - longitude: nil, - symbol_table_id: "/", - symbol_code: symbol_code, - comment: comment, - position_format: :compressed, - compression_type: compression_type, - data_type: :position, - compressed?: true, - position_ambiguity: Parser.Helpers.calculate_compressed_ambiguity(compression_type), - dao: nil, - course: nil, - speed: nil, - has_position: false - } - end + parse_position_compressed( + latitude_compressed, + longitude_compressed, + symbol_code, + cs, + compression_type, + comment + ) _ -> - %{ - latitude: nil, - longitude: nil, - timestamp: nil, - symbol_table_id: nil, - symbol_code: nil, - data_type: :malformed_position, - aprs_messaging?: false, - compressed?: false, - comment: String.trim(position_data), - dao: nil, - course: nil, - speed: nil, - has_position: false - } + parse_position_malformed(position_data) end end + defp parse_position_uncompressed(latitude, sym_table_id, longitude, symbol_code, comment) do + %{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude) + ambiguity = Parser.Helpers.calculate_position_ambiguity(latitude, longitude) + dao_data = parse_dao_extension(comment) + {course, speed} = extract_course_and_speed(comment) + + has_position = + (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, + symbol_table_id: sym_table_id, + symbol_code: symbol_code, + comment: comment, + data_type: :position, + aprs_messaging?: false, + compressed?: false, + position_ambiguity: ambiguity, + dao: dao_data, + course: course, + speed: speed, + 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 + end + + defp parse_position_short_uncompressed(latitude, sym_table_id, longitude) do + %{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude) + ambiguity = Parser.Helpers.calculate_position_ambiguity(latitude, longitude) + + has_position = + (is_number(lat) or is_struct(lat, Decimal)) and (is_number(lon) or is_struct(lon, Decimal)) + + %{ + latitude: lat, + longitude: lon, + timestamp: nil, + symbol_table_id: sym_table_id, + symbol_code: "_", + data_type: :position, + aprs_messaging?: false, + compressed?: false, + position_ambiguity: ambiguity, + dao: nil, + course: nil, + speed: nil, + has_position: has_position + } + end + + defp parse_position_compressed(latitude_compressed, longitude_compressed, symbol_code, cs, compression_type, comment) do + converted_lat = Parser.Helpers.convert_compressed_lat(latitude_compressed) + converted_lon = Parser.Helpers.convert_compressed_lon(longitude_compressed) + compressed_cs = Parser.Helpers.convert_compressed_cs(cs) + ambiguity = Parser.Helpers.calculate_compressed_ambiguity(compression_type) + + has_position = + (is_number(converted_lat) or is_struct(converted_lat, Decimal)) and + (is_number(converted_lon) or is_struct(converted_lon, Decimal)) + + base_data = %{ + latitude: converted_lat, + longitude: converted_lon, + symbol_table_id: "/", + symbol_code: symbol_code, + comment: comment, + position_format: :compressed, + compression_type: compression_type, + data_type: :position, + compressed?: true, + position_ambiguity: ambiguity, + dao: nil, + has_position: has_position + } + + Map.merge(base_data, compressed_cs) + rescue + _e -> + %{ + latitude: nil, + longitude: nil, + symbol_table_id: "/", + symbol_code: symbol_code, + comment: comment, + position_format: :compressed, + compression_type: compression_type, + data_type: :position, + compressed?: true, + position_ambiguity: Parser.Helpers.calculate_compressed_ambiguity(compression_type), + dao: nil, + course: nil, + speed: nil, + has_position: false + } + end + + defp parse_position_malformed(position_data) do + %{ + latitude: nil, + longitude: nil, + timestamp: nil, + symbol_table_id: nil, + symbol_code: nil, + data_type: :malformed_position, + aprs_messaging?: false, + compressed?: false, + comment: String.trim(position_data), + dao: nil, + course: nil, + speed: nil, + has_position: false + } + end + # Patch parse_position_with_message_without_timestamp to propagate course/speed @spec parse_position_with_message_without_timestamp(String.t()) :: map() def parse_position_with_message_without_timestamp(position_data) do @@ -1168,26 +1175,9 @@ defmodule Parser do case parse_callsign(sender) do {:ok, callsign_parts} -> base_callsign = List.first(callsign_parts) + ssid = extract_ssid(List.last(callsign_parts)) - ssid = - case List.last(callsign_parts) do - nil -> - 0 - - s when is_binary(s) -> - case Integer.parse(s) do - {i, _} -> i - :error -> 0 - end - - i when is_integer(i) -> - i - - _ -> - 0 - end - - case split_path(path) do + case split_path_for_tunnel(path) do {:ok, [destination, digi_path]} -> {:ok, %{ @@ -1211,6 +1201,22 @@ defmodule Parser do end end + defp extract_ssid(nil), do: 0 + + defp extract_ssid(s) when is_binary(s) do + case Integer.parse(s) do + {i, _} -> i + :error -> 0 + end + end + + defp extract_ssid(i) when is_integer(i), do: i + defp extract_ssid(_), do: 0 + + defp split_path_for_tunnel(path) do + split_path(path) + end + # Add network tunneling support @spec parse_network_tunnel(String.t()) :: {:ok, map()} | {:error, String.t()} defp parse_network_tunnel(packet) do diff --git a/lib/parser/helpers.ex b/lib/parser/helpers.ex index 72d14f6..8f02e4f 100644 --- a/lib/parser/helpers.ex +++ b/lib/parser/helpers.ex @@ -15,16 +15,7 @@ defmodule Parser.Helpers do case Float.parse(value) do {coord, _} -> coord = coord / 100.0 - - coord = - case direction do - "N" -> coord - "S" -> -coord - "E" -> coord - "W" -> -coord - _ -> {:error, "Invalid coordinate direction"} - end - + coord = apply_nmea_direction(coord, direction) if is_tuple(coord), do: coord, else: {:ok, coord} :error -> @@ -35,6 +26,16 @@ defmodule Parser.Helpers do @spec parse_nmea_coordinate(any(), any()) :: {:error, String.t()} def parse_nmea_coordinate(_, _), do: {:error, "Invalid coordinate format"} + defp apply_nmea_direction(coord, direction) do + case direction do + "N" -> coord + "S" -> -coord + "E" -> coord + "W" -> -coord + _ -> {:error, "Invalid coordinate direction"} + end + end + # PHG/DF helpers @spec parse_phg_power(integer()) :: phg_power def parse_phg_power(?0), do: {1, "1 watt"} @@ -193,40 +194,38 @@ defmodule Parser.Helpers do end def parse_analog_values(values) do - Enum.map(values, fn value -> - case value do - "" -> - nil + Enum.map(values, &parse_analog_value/1) + end - value -> - case Float.parse(value) do - {float_val, _} -> float_val - :error -> nil - end - end - end) + defp parse_analog_value("") do + nil + end + + defp parse_analog_value(value) do + case Float.parse(value) do + {float_val, _} -> float_val + :error -> nil + end end def parse_digital_values(values) do - values - |> Enum.map(fn value -> - cond do - value == "1" -> - true - - is_binary(value) -> - value - |> String.graphemes() - |> Enum.map(fn - "1" -> true - "0" -> false - _ -> nil - end) - end - end) - |> List.flatten() + Enum.flat_map(values, &parse_digital_value/1) end + defp parse_digital_value("1"), do: [true] + + defp parse_digital_value(value) when is_binary(value) do + value + |> String.graphemes() + |> Enum.map(fn + "1" -> true + "0" -> false + _ -> nil + end) + end + + defp parse_digital_value(_), do: [nil] + def parse_coefficient(coeff) do case Float.parse(coeff) do {float_val, _} -> diff --git a/lib/parser/mic_e.ex b/lib/parser/mic_e.ex index 3038a44..cc3a744 100644 --- a/lib/parser/mic_e.ex +++ b/lib/parser/mic_e.ex @@ -77,14 +77,14 @@ defmodule Parser.MicE do end end - defp decode_destination_digits([c1, c2, c3, c4, c5, c6]) do + defp decode_destination_digits([c1, c2, c3, d4, d5, d6]) do [ decode_digit(c1), decode_digit(c2), decode_digit(c3), - decode_digit(c4), - decode_digit(c5), - decode_digit(c6) + decode_digit(d4), + decode_digit(d5), + decode_digit(d6) ] end @@ -173,32 +173,11 @@ defmodule Parser.MicE do else <> = data - lon_deg = - case lon_deg_c - 28 do - d when d >= 108 and d <= 117 -> d - 80 - d when d >= 118 and d <= 127 -> d - 190 - d -> d - end + lon_offset - - lon_min = - case lon_min_c - 28 do - m when m >= 60 -> m - 60 - m -> m - end - + lon_deg = decode_lon_deg(lon_deg_c, lon_offset) + lon_min = decode_lon_min(lon_min_c) lon_hmin = lon_hmin_c - 28 - - sp = sp_c - 28 - dc = dc_c - 28 - se = se_c - 28 - - speed = div(sp, 10) * 100 + rem(sp, 10) * 10 + div(dc, 10) - speed = if speed >= 800, do: speed - 800, else: speed - # Convert to knots from mph - speed = speed * 0.868976 - - course = rem(dc, 10) * 100 + se - course = if course >= 400, do: course - 400, else: course + speed = decode_speed(sp_c, dc_c) + course = decode_course(dc_c, se_c) {:ok, %{ @@ -213,4 +192,34 @@ defmodule Parser.MicE do }} end end + + defp decode_lon_deg(lon_deg_c, lon_offset) do + case lon_deg_c - 28 do + d when d >= 108 and d <= 117 -> d - 80 + d when d >= 118 and d <= 127 -> d - 190 + d -> d + end + lon_offset + end + + defp decode_lon_min(lon_min_c) do + case lon_min_c - 28 do + m when m >= 60 -> m - 60 + m -> m + end + end + + defp decode_speed(sp_c, dc_c) do + sp = sp_c - 28 + dc = dc_c - 28 + speed = div(sp, 10) * 100 + rem(sp, 10) * 10 + div(dc, 10) + speed = if speed >= 800, do: speed - 800, else: speed + speed * 0.868976 + end + + defp decode_course(dc_c, se_c) do + dc = dc_c - 28 + se = se_c - 28 + course = rem(dc, 10) * 100 + se + if course >= 400, do: course - 400, else: course + end end diff --git a/test/parser/mic_e_test.exs b/test/parser/mic_e_test.exs new file mode 100644 index 0000000..387d0d6 --- /dev/null +++ b/test/parser/mic_e_test.exs @@ -0,0 +1,47 @@ +defmodule Parser.MicETest do + use ExUnit.Case, async: true + + alias Parser.MicE + + describe "parse/2" do + test "returns parsed map for valid Mic-E destination and data" do + # Example valid destination and data (values are illustrative) + destination = "ABCD12" + data = <<40, 41, 42, 43, 44, 45, 46, 47>> <> "rest" + result = MicE.parse(data, destination) + assert is_map(result) + assert result[:data_type] == :mic_e or result[:data_type] == :mic_e_error + end + + test "returns error map for invalid destination length" do + destination = "SHORT" + data = <<40, 41, 42, 43, 44, 45, 46, 47>> + result = MicE.parse(data, destination) + assert result[:data_type] == :mic_e_error + assert result[:latitude] == nil + assert result[:longitude] == nil + end + + test "returns error map for invalid information field length" do + destination = "ABCDEF" + data = <<1, 2, 3>> + result = MicE.parse(data, destination) + assert result[:data_type] == :mic_e_error + assert result[:latitude] == nil + assert result[:longitude] == nil + end + + test "returns error map for invalid characters in destination" do + destination = "!!!!!!" + data = <<40, 41, 42, 43, 44, 45, 46, 47>> + result = MicE.parse(data, destination) + assert result[:data_type] == :mic_e_error + end + + test "returns error map for nil destination" do + data = <<40, 41, 42, 43, 44, 45, 46, 47>> + result = MicE.parse(data, nil) + assert result[:data_type] == :mic_e_error + end + end +end diff --git a/test/parser/object_test.exs b/test/parser/object_test.exs index b19e0d8..588c773 100644 --- a/test/parser/object_test.exs +++ b/test/parser/object_test.exs @@ -18,5 +18,41 @@ defmodule Parser.ObjectTest do assert result[:data_type] == :object end end + + test "parses uncompressed object position" do + # 9-char name, 1 live/killed, 7 timestamp, 8 lat, 1 sym_table, 9 lon, 1 sym_code, comment + data = ";OBJECTNAM*1234567" <> "4903.50N/" <> "07201.75W" <> ">Test object" + result = Object.parse(data) + assert is_map(result) + assert result[:data_type] == :object + assert result[:position_format] == :uncompressed + assert result[:latitude] != nil + assert result[:longitude] != nil + end + + test "parses compressed object position" do + # 9-char name, 1 live/killed, 7 timestamp, compressed position + data = ";OBJECTNAM*1234567/abcdabcd>12!cTest compressed" + result = Object.parse(data) + assert is_map(result) + assert result[:data_type] == :object + assert result[:position_format] == :compressed + end + + test "parses unknown/fallback object position" do + data = ";OBJECTNAM*1234567unknownformat" + result = Object.parse(data) + assert is_map(result) + assert result[:data_type] == :object + assert result[:position_format] == :unknown + end + + test "parses fallback/other data" do + data = "not an object" + result = Object.parse(data) + assert is_map(result) + assert result[:data_type] == :object + assert result[:raw_data] == data + end end end diff --git a/test/parser/telemetry_test.exs b/test/parser/telemetry_test.exs index 25dd810..e4f4d20 100644 --- a/test/parser/telemetry_test.exs +++ b/test/parser/telemetry_test.exs @@ -18,5 +18,53 @@ defmodule Parser.TelemetryTest do assert Map.has_key?(result, :data_type) end end + + test "parses T# telemetry string with analog and digital values" do + result = Telemetry.parse("T#123,1,2,3,4,5,1,0,1,0,1,0,1") + assert is_map(result) + assert result[:data_type] == :telemetry + assert result[:sequence_number] == 123 + assert is_list(result[:analog_values]) + assert is_list(result[:digital_values]) + end + + test "parses :PARM. telemetry parameter names" do + result = Telemetry.parse(":PARM.A,B,C,D,E") + assert result[:data_type] == :telemetry_parameters + assert result[:parameter_names] == ["A", "B", "C", "D", "E"] + end + + test "parses :EQNS. telemetry equations" do + result = Telemetry.parse(":EQNS.1,2,3,4,5,6,7,8,9") + assert result[:data_type] == :telemetry_equations + assert is_list(result[:equations]) + assert Enum.all?(result[:equations], &is_map/1) + end + + test "parses :UNIT. telemetry units" do + result = Telemetry.parse(":UNIT.V,A,degC") + assert result[:data_type] == :telemetry_units + assert result[:units] == ["V", "A", "degC"] + end + + test "parses :BITS. telemetry bits sense and project names" do + result = Telemetry.parse(":BITS.101010,foo,bar") + assert result[:data_type] == :telemetry_bits + assert is_list(result[:bits_sense]) + assert result[:project_names] == ["foo", "bar"] + end + + test "parses :BITS. with no project names" do + result = Telemetry.parse(":BITS.101010") + assert result[:data_type] == :telemetry_bits + assert is_list(result[:bits_sense]) + assert result[:project_names] == [] + end + + test "parses unknown/fallback data" do + result = Telemetry.parse("random data") + assert result[:data_type] == :telemetry + assert result[:raw_data] == "random data" + end end end