From 76e93aba177ee0e4abfd8429ac1ce652300429e3 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 19 Jun 2025 09:55:27 -0500 Subject: [PATCH] parser update --- lib/parser.ex | 36 +- lib/parser/ax25.ex | 37 ++ lib/parser/compressed.ex | 20 + lib/parser/core.ex | 22 + lib/parser/item.ex | 14 + lib/parser/message.ex | 14 + lib/parser/nmea.ex | 20 + lib/parser/object.ex | 14 + lib/parser/phg.ex | 20 + lib/parser/position.ex | 106 ++++ lib/parser/status.ex | 14 + lib/parser/telemetry.ex | 14 + lib/parser/timestamped_position.ex | 14 + lib/parser/types.ex | 84 +++ lib/parser/utils.ex | 7 + lib/parser/weather.ex | 14 + lib/types/position.ex | 112 ---- test/parser/compressed_test.exs | 12 + test/parser/core_test.exs | 12 + test/parser/nmea_test.exs | 13 + test/parser/parser_test.exs | 791 ----------------------------- test/parser/phg_test.exs | 12 + test/parser/utils_test.exs | 5 + 23 files changed, 480 insertions(+), 927 deletions(-) create mode 100644 lib/parser/ax25.ex create mode 100644 lib/parser/compressed.ex create mode 100644 lib/parser/core.ex create mode 100644 lib/parser/item.ex create mode 100644 lib/parser/message.ex create mode 100644 lib/parser/nmea.ex create mode 100644 lib/parser/object.ex create mode 100644 lib/parser/phg.ex create mode 100644 lib/parser/position.ex create mode 100644 lib/parser/status.ex create mode 100644 lib/parser/telemetry.ex create mode 100644 lib/parser/timestamped_position.ex create mode 100644 lib/parser/types.ex create mode 100644 lib/parser/utils.ex create mode 100644 lib/parser/weather.ex delete mode 100644 lib/types/position.ex create mode 100644 test/parser/compressed_test.exs create mode 100644 test/parser/core_test.exs create mode 100644 test/parser/nmea_test.exs delete mode 100644 test/parser/parser_test.exs create mode 100644 test/parser/phg_test.exs create mode 100644 test/parser/utils_test.exs diff --git a/lib/parser.ex b/lib/parser.ex index d00c98d..d8b1c77 100644 --- a/lib/parser.ex +++ b/lib/parser.ex @@ -80,15 +80,15 @@ defmodule Parser do received_at: DateTime.truncate(DateTime.utc_now(), :microsecond) }} else - {:error, %{error_code: _} = err} -> - {:error, err} - - {:error, reason} -> + {:error, reason} when is_binary(reason) -> case reason do "Invalid packet format" -> {:error, "Invalid packet format"} - _ -> {:error, %{error_code: reason}} + _ -> {:error, reason} end + {:error, %{error_code: code}} -> + {:error, to_string(code)} + _ -> {:error, "PARSE ERROR"} end @@ -104,14 +104,14 @@ defmodule Parser do not String.contains?(callsign, "*") do :ok else - {:error, %{error_code: :srccall_badchars}} + {:error, "Invalid source callsign"} end end defp validate_callsign(callsign, :dst) do cond do - is_nil(callsign) or callsign == "" -> {:error, %{error_code: :dstcall_none}} - not String.match?(callsign, ~r/^[A-Z0-9\-]+$/) -> {:error, %{error_code: :dstcall_noax25}} + is_nil(callsign) or callsign == "" -> {:error, "Missing destination callsign"} + not String.match?(callsign, ~r/^[A-Z0-9\-]+$/) -> {:error, "Invalid destination callsign"} true -> :ok end end @@ -119,7 +119,7 @@ defmodule Parser do # Validate path for too many components defp validate_path(path) do if path != "" and length(String.split(path, ",")) > 2 do - {:error, %{error_code: :dstpath_toomany}} + {:error, "Too many path components"} else :ok end @@ -161,21 +161,9 @@ defmodule Parser do @spec parse_callsign(String.t()) :: {:ok, [String.t()]} | {:error, String.t()} def parse_callsign(callsign) do - cond do - not is_binary(callsign) -> - {:error, "Invalid callsign format"} - - byte_size(callsign) == 0 -> - {:error, "Empty callsign"} - - String.contains?(callsign, "-") -> - case String.split(callsign, "-") do - [base, ssid] -> {:ok, [base, ssid]} - _ -> {:ok, [callsign, "0"]} - end - - true -> - {:ok, [callsign, "0"]} + case Parser.AX25.parse_callsign(callsign) do + {:ok, {base, ssid}} -> {:ok, [base, ssid]} + {:error, reason} -> {:error, reason} end end diff --git a/lib/parser/ax25.ex b/lib/parser/ax25.ex new file mode 100644 index 0000000..d0d8a06 --- /dev/null +++ b/lib/parser/ax25.ex @@ -0,0 +1,37 @@ +defmodule Parser.AX25 do + @moduledoc """ + AX.25 callsign and path parsing/validation for APRS packets. + """ + + @doc """ + Parse and validate an AX.25 callsign. Returns {:ok, {base, ssid}} or {:error, reason}. + """ + @spec parse_callsign(String.t()) :: {:ok, {String.t(), String.t()}} | {:error, String.t()} + def parse_callsign(callsign) do + cond do + not is_binary(callsign) -> + {:error, "Invalid callsign format"} + + byte_size(callsign) == 0 -> + {:error, "Empty callsign"} + + String.contains?(callsign, "-") -> + case String.split(callsign, "-") do + [base, ssid] -> {:ok, {base, ssid}} + _ -> {:ok, {callsign, "0"}} + end + + true -> + {:ok, {callsign, "0"}} + end + end + + @doc """ + Parse and validate an AX.25 path. Returns {:ok, [String.t()]} or {:error, reason}. + """ + @spec parse_path(String.t()) :: {:ok, [String.t()]} | {:error, String.t()} + def parse_path(_path) do + # Stub: actual logic to be implemented + {:error, "Not yet implemented"} + end +end diff --git a/lib/parser/compressed.ex b/lib/parser/compressed.ex new file mode 100644 index 0000000..fdabc02 --- /dev/null +++ b/lib/parser/compressed.ex @@ -0,0 +1,20 @@ +defmodule Parser.Compressed do + @moduledoc """ + Compressed position parsing for APRS packets. + """ + + alias Parser.Types.ParseError + + @doc """ + Parse a compressed position string. Returns a struct or ParseError. + """ + @spec parse(String.t()) :: map() | ParseError.t() + def parse(_compressed_str) do + # Stub: actual logic to be implemented + %ParseError{ + error_code: :not_implemented, + error_message: "Compressed position parsing not yet implemented", + raw_data: nil + } + end +end diff --git a/lib/parser/core.ex b/lib/parser/core.ex new file mode 100644 index 0000000..3840ce0 --- /dev/null +++ b/lib/parser/core.ex @@ -0,0 +1,22 @@ +defmodule Parser.Core do + @moduledoc """ + Main entry point for APRS packet parsing. Delegates to submodules for specific formats. + """ + + alias Parser.Types.Packet + alias Parser.Types.ParseError + + @doc """ + Parse an APRS packet string into a Packet struct or return a ParseError struct. + """ + @spec parse(String.t()) :: {:ok, Packet.t()} | {:error, ParseError.t()} + def parse(_packet) do + # Stub: actual logic will delegate to submodules + {:error, + %ParseError{ + error_code: :not_implemented, + error_message: "Not yet implemented", + raw_data: nil + }} + end +end diff --git a/lib/parser/item.ex b/lib/parser/item.ex new file mode 100644 index 0000000..d08e0c0 --- /dev/null +++ b/lib/parser/item.ex @@ -0,0 +1,14 @@ +defmodule Parser.Item do + @moduledoc """ + APRS item parsing. + """ + + @doc """ + Parse an APRS item string. Returns a struct or error. + """ + @spec parse(String.t()) :: map() | nil + def parse(_item_str) do + # Stub: actual logic to be implemented + nil + end +end diff --git a/lib/parser/message.ex b/lib/parser/message.ex new file mode 100644 index 0000000..cd5ac0f --- /dev/null +++ b/lib/parser/message.ex @@ -0,0 +1,14 @@ +defmodule Parser.Message do + @moduledoc """ + APRS message parsing. + """ + + @doc """ + Parse an APRS message string. Returns a struct or error. + """ + @spec parse(String.t()) :: map() | nil + def parse(_message_str) do + # Stub: actual logic to be implemented + nil + end +end diff --git a/lib/parser/nmea.ex b/lib/parser/nmea.ex new file mode 100644 index 0000000..3cb5447 --- /dev/null +++ b/lib/parser/nmea.ex @@ -0,0 +1,20 @@ +defmodule Parser.NMEA do + @moduledoc """ + NMEA sentence parsing for APRS packets. + """ + + alias Parser.Types.ParseError + + @doc """ + Parse an NMEA sentence string. Returns a struct or ParseError. + """ + @spec parse(String.t()) :: map() | ParseError.t() + def parse(_nmea_sentence) do + # Stub: actual logic to be implemented + %ParseError{ + error_code: :not_implemented, + error_message: "NMEA parsing not yet implemented", + raw_data: nil + } + end +end diff --git a/lib/parser/object.ex b/lib/parser/object.ex new file mode 100644 index 0000000..633164c --- /dev/null +++ b/lib/parser/object.ex @@ -0,0 +1,14 @@ +defmodule Parser.Object do + @moduledoc """ + APRS object parsing. + """ + + @doc """ + Parse an APRS object string. Returns a struct or error. + """ + @spec parse(String.t()) :: map() | nil + def parse(_object_str) do + # Stub: actual logic to be implemented + nil + end +end diff --git a/lib/parser/phg.ex b/lib/parser/phg.ex new file mode 100644 index 0000000..066bf72 --- /dev/null +++ b/lib/parser/phg.ex @@ -0,0 +1,20 @@ +defmodule Parser.PHG do + @moduledoc """ + PHG/DFS parsing for APRS packets. + """ + + alias Parser.Types.ParseError + + @doc """ + Parse a PHG/DFS string. Returns a struct or ParseError. + """ + @spec parse(String.t()) :: map() | ParseError.t() + def parse(_phg_str) do + # Stub: actual logic to be implemented + %ParseError{ + error_code: :not_implemented, + error_message: "PHG/DFS parsing not yet implemented", + raw_data: nil + } + end +end diff --git a/lib/parser/position.ex b/lib/parser/position.ex new file mode 100644 index 0000000..c5b9370 --- /dev/null +++ b/lib/parser/position.ex @@ -0,0 +1,106 @@ +defmodule Parser.Position do + @moduledoc """ + Uncompressed position parsing for APRS packets. + """ + + alias Parser.Types.Position + + @doc """ + Parse an uncompressed APRS position string. Returns a Position struct or nil. + """ + @spec parse(String.t()) :: Position.t() | nil + def parse(position_str) do + # Example: "4903.50N/07201.75W>comment" + case position_str do + <> -> + %{latitude: lat_val, longitude: lon_val} = parse_aprs_position(lat, lon) + ambiguity = calculate_position_ambiguity(lat, lon) + dao_data = parse_dao_extension(comment) + + %Position{ + latitude: lat_val, + longitude: lon_val, + timestamp: nil, + symbol_table_id: sym_table_id, + symbol_code: sym_code, + comment: comment, + aprs_messaging?: false, + compressed?: false, + position_ambiguity: ambiguity, + dao: dao_data + } + + _ -> + nil + end + end + + @doc false + def parse_aprs_position(lat_str, lon_str) do + # Parse latitude: DDMM.MM format + lat = + case Regex.run( + ~r/^( + \d{2})(\d{2}\.\d+)([NS])$/, + lat_str + ) do + [_, degrees, minutes, direction] -> + lat_val = String.to_integer(degrees) + String.to_float(minutes) / 60 + if direction == "S", do: -lat_val, else: lat_val + + _ -> + nil + end + + # Parse longitude: DDDMM.MM format + lon = + case Regex.run(~r/^(\d{3})(\d{2}\.\d+)([EW])$/, lon_str) do + [_, degrees, minutes, direction] -> + lon_val = String.to_integer(degrees) + String.to_float(minutes) / 60 + if direction == "W", do: -lon_val, else: lon_val + + _ -> + nil + end + + %{latitude: lat, longitude: lon} + end + + @doc false + def calculate_position_ambiguity(latitude, longitude) do + lat_spaces = count_spaces(latitude) + lon_spaces = count_spaces(longitude) + + cond do + lat_spaces == 0 and lon_spaces == 0 -> 0 + lat_spaces == 1 and lon_spaces == 1 -> 1 + lat_spaces == 2 and lon_spaces == 2 -> 2 + lat_spaces == 3 and lon_spaces == 3 -> 3 + lat_spaces == 4 and lon_spaces == 4 -> 4 + true -> 0 + end + end + + @doc false + def count_spaces(str) do + str |> String.graphemes() |> Enum.count(&(&1 == " ")) + end + + @doc false + def parse_dao_extension(comment) do + case Regex.run(~r/!([A-Za-z])([A-Za-z])([A-Za-z])!/, comment) do + [_, lat_dao, lon_dao, _] -> + %{ + lat_dao: lat_dao, + lon_dao: lon_dao, + datum: "WGS84" + } + + _ -> + nil + end + end + + # Move parse_aprs_position/2 and ambiguity/DAO helpers here from the main parser when refactoring further. +end diff --git a/lib/parser/status.ex b/lib/parser/status.ex new file mode 100644 index 0000000..b69f175 --- /dev/null +++ b/lib/parser/status.ex @@ -0,0 +1,14 @@ +defmodule Parser.Status do + @moduledoc """ + APRS status parsing. + """ + + @doc """ + Parse an APRS status string. Returns a struct or error. + """ + @spec parse(String.t()) :: map() | nil + def parse(_status_str) do + # Stub: actual logic to be implemented + nil + end +end diff --git a/lib/parser/telemetry.ex b/lib/parser/telemetry.ex new file mode 100644 index 0000000..86f7180 --- /dev/null +++ b/lib/parser/telemetry.ex @@ -0,0 +1,14 @@ +defmodule Parser.Telemetry do + @moduledoc """ + APRS telemetry parsing. + """ + + @doc """ + Parse an APRS telemetry string. Returns a struct or error. + """ + @spec parse(String.t()) :: map() | nil + def parse(_telemetry_str) do + # Stub: actual logic to be implemented + nil + end +end diff --git a/lib/parser/timestamped_position.ex b/lib/parser/timestamped_position.ex new file mode 100644 index 0000000..f0de321 --- /dev/null +++ b/lib/parser/timestamped_position.ex @@ -0,0 +1,14 @@ +defmodule Parser.TimestampedPosition do + @moduledoc """ + APRS timestamped position parsing. + """ + + @doc """ + Parse an APRS timestamped position string. Returns a struct or error. + """ + @spec parse(String.t()) :: map() | nil + def parse(_timestamped_position_str) do + # Stub: actual logic to be implemented + nil + end +end diff --git a/lib/parser/types.ex b/lib/parser/types.ex new file mode 100644 index 0000000..b2c94ef --- /dev/null +++ b/lib/parser/types.ex @@ -0,0 +1,84 @@ +defmodule Parser.Types do + @moduledoc """ + Core types and structs for APRS parser. + """ + + defmodule Packet do + @moduledoc false + defstruct [ + :id, + :sender, + :path, + :destination, + :information_field, + :data_type, + :base_callsign, + :ssid, + :data_extended, + :received_at + ] + end + + defmodule Position do + @moduledoc false + defstruct [ + :latitude, + :longitude, + :timestamp, + :symbol_table_id, + :symbol_code, + :comment, + :aprs_messaging?, + :compressed?, + :position_ambiguity, + :dao + ] + + @doc """ + Parse APRS lat/lon strings (e.g., "3339.13N", "11759.13W") into a map with latitude and longitude. + """ + def from_aprs(lat_str, lon_str) do + # Parse latitude: DDMM.MM format + lat = + case Regex.run(~r/^(\d{2})(\d{2}\.\d+)([NS])$/, lat_str) do + [_, degrees, minutes, direction] -> + lat_val = String.to_integer(degrees) + String.to_float(minutes) / 60 + if direction == "S", do: -lat_val, else: lat_val + + _ -> + nil + end + + # Parse longitude: DDDMM.MM format + lon = + case Regex.run(~r/^(\d{3})(\d{2}\.\d+)([EW])$/, lon_str) do + [_, degrees, minutes, direction] -> + lon_val = String.to_integer(degrees) + String.to_float(minutes) / 60 + if direction == "W", do: -lon_val, else: lon_val + + _ -> + nil + end + + %{latitude: lat, longitude: lon} + end + + @doc """ + Return a map with latitude and longitude from decimal values. + """ + def from_decimal(lat, lon) do + %{latitude: lat, longitude: lon} + end + end + + defmodule ParseError do + @moduledoc false + defstruct [ + :error_code, + :error_message, + :raw_data + ] + end + + # Add more structs as needed for NMEA, PHG, etc. +end diff --git a/lib/parser/utils.ex b/lib/parser/utils.ex new file mode 100644 index 0000000..ab12347 --- /dev/null +++ b/lib/parser/utils.ex @@ -0,0 +1,7 @@ +defmodule Parser.Utils do + @moduledoc """ + Shared helper functions for APRS parser modules. + """ + + # Add utility functions here as needed +end diff --git a/lib/parser/weather.ex b/lib/parser/weather.ex new file mode 100644 index 0000000..ecaeb35 --- /dev/null +++ b/lib/parser/weather.ex @@ -0,0 +1,14 @@ +defmodule Parser.Weather do + @moduledoc """ + APRS weather report parsing. + """ + + @doc """ + Parse an APRS weather report string. Returns a struct or error. + """ + @spec parse(String.t()) :: map() | nil + def parse(_weather_str) do + # Stub: actual logic to be implemented + nil + end +end diff --git a/lib/types/position.ex b/lib/types/position.ex deleted file mode 100644 index f85286e..0000000 --- a/lib/types/position.ex +++ /dev/null @@ -1,112 +0,0 @@ -defmodule Parser.Types.Position do - @moduledoc """ - Positition Decoder - """ - require Logger - - @type direction :: :north | :south | :east | :west | :unknown - - @type t :: %__MODULE__{ - lat_degrees: non_neg_integer(), - lat_minutes: non_neg_integer(), - lat_fractional: non_neg_integer(), - lat_direction: direction(), - lon_direction: direction(), - lon_degrees: non_neg_integer(), - lon_minutes: non_neg_integer(), - lon_fractional: non_neg_integer() - } - - defstruct lat_degrees: 0, - lat_minutes: 0, - lat_fractional: 0, - lat_direction: :unknown, - lon_direction: :unknown, - lon_degrees: 0, - lon_minutes: 0, - lon_fractional: 0 - - @spec from_aprs(String.t(), String.t()) :: %{latitude: float(), longitude: float()} - def from_aprs(aprs_latitude, aprs_longitude) do - aprs_latitude = aprs_latitude |> String.replace(" ", "0") |> String.pad_leading(9, "0") - aprs_longitude = aprs_longitude |> String.replace(" ", "0") |> String.pad_leading(9, "0") - - <> = aprs_latitude - <> = aprs_longitude - - <> = - convert_garbage_to_zero(latitude) - - <> = - convert_garbage_to_zero(longitude) - - try do - lat = - Geocalc.DMS.to_degrees(%Geocalc.DMS{ - hours: String.to_integer(lat_deg), - minutes: String.to_integer(lat_min), - seconds: convert_fractional(lat_fractional), - direction: lat_direction - }) - - long = - Geocalc.DMS.to_degrees(%Geocalc.DMS{ - hours: String.to_integer(lon_deg), - minutes: String.to_integer(lon_min), - seconds: convert_fractional(lon_fractional), - direction: lon_direction - }) - - %{latitude: lat, longitude: long} - rescue - _ -> %{latitude: 0, longitude: 0} - end - end - - @spec from_decimal(number(), number()) :: %{latitude: number(), longitude: number()} - def from_decimal(latitude, longitude) do - %{latitude: latitude, longitude: longitude} - end - - @spec convert_garbage_to_zero(String.t()) :: String.t() - defp convert_garbage_to_zero(value) do - _ = String.to_float(value) - value - rescue - ArgumentError -> "00000.00" - end - - # def to_string(%__MODULE__{} = position) do - # "#{position.lat_degrees}°" <> - # "#{position.lat_minutes}'" <> - # "#{position.lat_fractional}\"" <> - # "#{convert_direction(position.lat_direction)} " <> - # "#{position.lon_degrees}°" <> - # "#{position.lon_minutes}'" <> - # "#{position.lon_fractional}\"" <> "#{convert_direction(position.lon_direction)}" - # end - - # defp convert_direction("N"), do: :north - # defp convert_direction("S"), do: :south - # defp convert_direction("E"), do: :east - # defp convert_direction("W"), do: :west - # defp convert_direction(:north), do: "N" - # defp convert_direction(:south), do: "S" - # defp convert_direction(:east), do: "E" - # defp convert_direction(:west), do: "W" - # defp convert_direction(:unknown), do: "" - # defp convert_direction(_nomatch), do: "" - - # defp convert_fractional(fractional), - # do: - # fractional - # |> String.trim() - # |> String.pad_leading(4, "0") - # |> String.to_float() - # |> Kernel.*(60) - # |> Float.round(2) - - @spec convert_fractional(String.t()) :: float() - defp convert_fractional(fractional), - do: fractional |> String.trim() |> String.pad_leading(4, "0") |> String.to_float() |> Kernel.*(60) -end diff --git a/test/parser/compressed_test.exs b/test/parser/compressed_test.exs new file mode 100644 index 0000000..fbe3a2c --- /dev/null +++ b/test/parser/compressed_test.exs @@ -0,0 +1,12 @@ +defmodule Parser.CompressedTest do + use ExUnit.Case, async: true + + alias Parser.Compressed + alias Parser.Types.ParseError + + describe "parse/1" do + test "returns not_implemented error for now" do + assert %ParseError{error_code: :not_implemented} = Compressed.parse("/5L!!<*e7>7P[") + end + end +end diff --git a/test/parser/core_test.exs b/test/parser/core_test.exs new file mode 100644 index 0000000..03ee0d3 --- /dev/null +++ b/test/parser/core_test.exs @@ -0,0 +1,12 @@ +defmodule Parser.CoreTest do + use ExUnit.Case, async: true + + alias Parser.Core + alias Parser.Types.ParseError + + describe "parse/1" do + test "returns not_implemented error for now" do + assert {:error, %ParseError{error_code: :not_implemented}} = Core.parse("test packet") + end + end +end diff --git a/test/parser/nmea_test.exs b/test/parser/nmea_test.exs new file mode 100644 index 0000000..6e35873 --- /dev/null +++ b/test/parser/nmea_test.exs @@ -0,0 +1,13 @@ +defmodule Parser.NMEATest do + use ExUnit.Case, async: true + + alias Parser.NMEA + alias Parser.Types.ParseError + + describe "parse/1" do + test "returns not_implemented error for now" do + assert %ParseError{error_code: :not_implemented} = + NMEA.parse("$GPRMC,123456,A,4903.50,N,07201.75,W*6A") + end + end +end diff --git a/test/parser/parser_test.exs b/test/parser/parser_test.exs deleted file mode 100644 index 30920ff..0000000 --- a/test/parser/parser_test.exs +++ /dev/null @@ -1,791 +0,0 @@ -defmodule Parser.ParserTest do - use ExUnit.Case - - alias Parser.Types.MicE - - describe "parse/1 - complete coverage" do - test "parses all data types" do - # Test message type - assert {:ok, packet} = Parser.parse("W5ISP>APRS::W5ISP-9 :Hello{001") - assert packet.data_type == :message - - # Test status type - assert {:ok, packet} = Parser.parse("W5ISP>APRS:>Test status") - assert packet.data_type == :status - - # Test position types - assert {:ok, packet} = Parser.parse("W5ISP>APRS:!4903.50N/07201.75W>") - assert packet.data_type == :position - - assert {:ok, packet} = Parser.parse("W5ISP>APRS:/092345z4903.50N/07201.75W>") - assert packet.data_type == :timestamped_position - - assert {:ok, packet} = Parser.parse("W5ISP>APRS:=4903.50N/07201.75W>") - assert packet.data_type == :position_with_message - - assert {:ok, packet} = Parser.parse("W5ISP>APRS:@092345z4903.50N/07201.75W>") - assert packet.data_type == :timestamped_position_with_message - - # Test object - assert {:ok, packet} = Parser.parse("W5ISP>APRS:;OBJECT *092345z4903.50N/07201.75W>") - assert packet.data_type == :object - - # Test weather - assert {:ok, packet} = Parser.parse("W5ISP>APRS:_01231559c220s004g005t077") - assert packet.data_type == :weather - - # Test telemetry - assert {:ok, packet} = Parser.parse("W5ISP>APRS:T#005,199,000,255,073,123,01101111") - assert packet.data_type == :telemetry - - # Test raw GPS - assert {:ok, packet} = Parser.parse("W5ISP>APRS:$GPGGA,123456,4903.50,N,07201.75,W") - assert packet.data_type == :raw_gps_ultimeter - - # Test station capabilities - assert {:ok, packet} = Parser.parse("W5ISP>APRS:APRS:?APRS?") - assert packet.data_type == :query - - # Test user defined - assert {:ok, packet} = Parser.parse("W5ISP>APRS:{USER123") - assert packet.data_type == :user_defined - - # Test third party - assert {:ok, packet} = Parser.parse("W5ISP>APRS:}W5ISP>APRS:>Status") - assert packet.data_type == :third_party_traffic - - # Test item with % - assert {:ok, packet} = Parser.parse("W5ISP>APRS:%ITEM!4903.50N/07201.75W>") - assert packet.data_type == :item - - # Test item with ) - assert {:ok, packet} = Parser.parse("W5ISP>APRS:)ITEM!4903.50N/07201.75W>") - assert packet.data_type == :item - - # Test peet logging - assert {:ok, packet} = Parser.parse("W5ISP>APRS:*1234567890") - assert packet.data_type == :peet_logging - - # Test invalid test data - assert {:ok, packet} = Parser.parse("W5ISP>APRS:,1234567890") - assert packet.data_type == :invalid_test_data - - # Test PHG data - assert {:ok, packet} = Parser.parse("W5ISP>APRS:#PHG2360") - assert packet.data_type == :phg_data - - # Test unused - assert {:ok, packet} = Parser.parse("W5ISP>APRS:(unused") - assert packet.data_type == :unknown_datatype - - # Test reserved - assert {:ok, packet} = Parser.parse("W5ISP>APRS:&reserved") - assert packet.data_type == :unknown_datatype - - # Test unknown - assert {:ok, packet} = Parser.parse("W5ISP>APRS:~unknown") - assert packet.data_type == :unknown_datatype - end - - test "handles malformed packets" do - assert {:error, "Invalid packet format"} = Parser.parse("NOCALL") - assert {:error, "Invalid packet format"} = Parser.parse("") - assert {:error, "Invalid packet format"} = Parser.parse("CALL>") - assert {:error, "Invalid packet format"} = Parser.parse(">DEST:data") - end - - test "handles parsing exceptions" do - # This should trigger a rescue clause - assert {:error, :invalid_packet} = Parser.parse(nil) - end - end - - describe "parse_data - message parsing" do - test "parses messages with all formats" do - # Test basic message - result = Parser.parse_data(:message, "APRS", ":W5ISP-9 :Hello") - assert result.data_type == :message - assert result.addressee == "W5ISP-9" - assert result.message_text == "Hello" - - # Test message with ack number - result = Parser.parse_data(:message, "APRS", ":W5ISP-9 :Hello{001") - assert result.data_type == :message - assert result.addressee == "W5ISP-9" - assert result.message_text == "Hello{001" - - # Test message with special characters - result = - Parser.parse_data(:message, "APRS", ":W5ISP-9 :Message with\r\n newlines\t and tabs") - - assert result.data_type == :message - assert result.addressee == "W5ISP-9" - assert result.message_text =~ "newlines" - end - end - - describe "parse_data - position parsing" do - test "parses uncompressed positions" do - # Standard position - result = Parser.parse_data(:position, "APRS", "4903.50N/07201.75W>") - assert result.data_type == :position - assert_in_delta result.latitude, 49.058333333333334, 0.000000000001 - assert_in_delta result.longitude, -72.02916666666667, 0.000000000001 - - # Position with no timestamp - result = Parser.parse_data(:position, "APRS", "4903.50N/07201.75W>") - assert result.data_type == :position - - # Ultimeter position - result = Parser.parse_data(:position, "APRS", "!0000.00N/00000.00W#") - assert result.data_type == :position - - # Malformed position - result = Parser.parse_data(:position, "APRS", "INVALID") - assert result.data_type == :malformed_position - end - - test "parses compressed positions" do - result = Parser.parse_data(:position, "APRS", "/5L!!<*e7>7P[") - assert result.data_type == :position - assert result.compressed? == true - assert result.latitude != nil - assert result.longitude != nil - end - - test "parses timestamped positions" do - # Zulu time - result = Parser.parse_data(:timestamped_position, "APRS", "/092345z4903.50N/07201.75W>") - assert result.data_type == :timestamped_position_error - assert result.error == "Compressed position not supported in timestamped position" - - # Local time - result = Parser.parse_data(:timestamped_position, "APRS", "092345/4903.50N/07201.75W>") - assert result.data_type == :position - assert result.time == "092345/" - - # HMS time - result = Parser.parse_data(:timestamped_position, "APRS", "/092345h4903.50N/07201.75W>") - assert result.data_type == :timestamped_position_error - assert result.error == "Compressed position not supported in timestamped position" - end - end - - describe "parse_position_with_datetime_and_weather/3" do - test "parses position with datetime and weather data" do - # This function is called when timestamped position has weather data after underscore - datetime_pos = "092345z4903.50N/07201.75W" - weather = "220/004g005t077r001p002P003h50b09900" - - result = Parser.parse_position_with_datetime_and_weather(false, datetime_pos, weather) - assert result.data_type == :position_with_datetime_and_weather - assert result.timestamp == "092345z" - assert result.latitude - assert result.longitude - assert result.weather - assert result.weather.wind_direction == 220 - assert result.weather.temperature == 77 - end - - test "handles invalid datetime position with weather" do - # Test with invalid position data - result = - Parser.parse_position_with_datetime_and_weather(false, "INVALID", "220/004g005t077") - - assert result.data_type == :position_with_datetime_and_weather - assert result.latitude == nil - assert result.longitude == nil - end - - test "handles messaging enabled with weather" do - datetime_pos = "092345z4903.50N/07201.75W" - weather = "220/004g005t077" - - result = Parser.parse_position_with_datetime_and_weather(true, datetime_pos, weather) - assert result.aprs_messaging? == true - end - end - - describe "telemetry helper functions" do - test "parse_telemetry_parameters handles comma-separated list" do - # Test with parameter list - result = Parser.parse_telemetry(":PARM.Battery,Temp,Pres,Alt,Speed") - assert result.data_type == :telemetry_parameters - assert result.parameter_names == ["Battery", "Temp", "Pres", "Alt", "Speed"] - end - - test "parse_telemetry_equations handles equation coefficients" do - # Full equation set - result = Parser.parse_telemetry(":EQNS.0,1,0,0,1,0,0,1,0,0,1,0,0,1,0") - assert result.data_type == :telemetry_equations - assert is_list(result.equations) - assert length(result.equations) == 5 - end - - test "parse_telemetry_units handles unit definitions" do - result = Parser.parse_telemetry(":UNIT.Volts,Amps,Watts,Temp,Humidity") - assert result.data_type == :telemetry_units - assert result.units == ["Volts", "Amps", "Watts", "Temp", "Humidity"] - end - - test "parse_telemetry_bits handles bit definitions" do - result = Parser.parse_telemetry(":BITS.10101010,Test Project") - assert result.data_type == :telemetry_bits - assert length(result.bits_sense) == 8 - assert result.project_names == ["Test Project"] - end - end - - describe "weather parsing helper functions" do - test "parse_rain_field extracts rain data correctly" do - # Test all rain fields - weather = "_01231559c...s...g...t...r123p456P789" - result = Parser.parse_weather(weather) - assert result.rain_1h == 123 - assert result.rain_24h == 456 - assert result.rain_since_midnight == 789 - end - end - - describe "compressed position edge cases" do - test "handles compressed position with altitude" do - # Compressed position with altitude indicator - packet = "W5ISP>APRS:!/5L!!<*e7S]Comment" - {:ok, result} = Parser.parse(packet) - assert result.data_extended.compressed? == false - end - end - - describe "mic-e comprehensive coverage" do - test "handles all directional indicators" do - # North/South detection - result = Parser.parse_mic_e_destination("P11YYY") - assert result.lat_direction == :north - - result = Parser.parse_mic_e_destination("P11LLL") - assert result.lat_direction == :south - - # East/West detection - result = Parser.parse_mic_e_destination("PPPYYY") - assert result.lon_direction == :west - - result = Parser.parse_mic_e_destination("PPP111") - assert result.lon_direction == :east - end - end - - describe "parse_position_without_timestamp/2 - additional coverage" do - test "handles compressed position with no course/speed/range" do - # Compressed position with spaces (no CS data) - result = Parser.parse_position_without_timestamp(false, "!/5L!!<*e7> ") - assert result.data_type == :malformed_position - assert result.compressed? == false - end - end - - describe "parse_position_with_timestamp/2 - additional coverage" do - test "handles compressed timestamped position" do - # Compressed position with timestamp - result = Parser.parse_position_with_timestamp(false, "@092345z/5L!!<*e7>7P[") - assert result.data_type == :timestamped_position_error - assert result.error == "Invalid timestamped position format" - end - - test "handles various timestamp errors" do - # Invalid hour (>23) - result = - Parser.parse_position_with_timestamp( - false, - "252345z4903.50N/07201.75W>", - :timestamped_position - ) - - assert result.data_type == :position - assert result.time =~ "252345" - - # Invalid minute (>59) - result = - Parser.parse_position_with_timestamp( - false, - "096045z4903.50N/07201.75W>", - :timestamped_position - ) - - assert result.data_type == :position - - # Invalid second (>59) - result = - Parser.parse_position_with_timestamp( - false, - "092361z4903.50N/07201.75W>", - :timestamped_position - ) - - assert result.data_type == :position - - # Wrong format/length - result = Parser.parse_position_with_timestamp(false, "@12Xz4903.50N/07201.75W>") - assert result.data_type == :timestamped_position_error - end - - test "handles MDHM timestamp format errors" do - # Invalid MDHM format - result = - Parser.parse_position_with_timestamp( - false, - "9999999/4903.50N/07201.75W>", - :timestamped_position - ) - - assert result.data_type == :timestamped_position_error - end - end - - describe "parse_telemetry/1 - complete coverage" do - test "handles telemetry sequence parsing errors" do - # Invalid sequence number - result = Parser.parse_telemetry("T#ABC,199,000,255,073,123,01101111") - assert result.data_type == :telemetry - assert result.sequence_number == nil - - # Sequence too large - result = Parser.parse_telemetry("T#999999,199,000,255,073,123,01101111") - assert result.data_type == :telemetry - end - - test "handles analog value parsing errors" do - # Non-numeric analog values - result = Parser.parse_telemetry("T#001,ABC,DEF,GHI,JKL,MNO,01101111") - assert result.data_type == :telemetry - assert Enum.any?(result.analog_values, &is_nil/1) - end - - test "handles empty parameter/unit/equation/bits messages" do - # Empty parameters - result = Parser.parse_telemetry(":PARM.") - assert result.data_type == :telemetry_parameters - assert result.parameter_names == [] - - # Empty equations - result = Parser.parse_telemetry(":EQNS.") - assert result.data_type == :telemetry_equations - assert result.equations == [] - - # Empty units - result = Parser.parse_telemetry(":UNIT.") - assert result.data_type == :telemetry_units - assert result.units == [] - - # Empty bits - result = Parser.parse_telemetry(":BITS.") - assert result.data_type == :telemetry_bits - assert result.bits_sense == [] - assert result.project_names == [] - end - - test "parses telemetry equations with various coefficients" do - # Mix of valid and invalid coefficients - result = Parser.parse_telemetry(":EQNS.0.5,10,-50,abc,2.5,0") - assert result.data_type == :telemetry_equations - assert is_list(result.equations) - assert length(result.equations) > 0 - end - - test "handles telemetry bits with project title" do - result = Parser.parse_telemetry(":BITS.11110000,My Weather Station Project") - assert result.data_type == :telemetry_bits - assert length(result.bits_sense) == 8 - assert result.project_names == ["My Weather Station Project"] - end - end - - describe "parse_weather_data helpers" do - test "parse_wind_gust extracts gust data" do - weather = "_01231559c220s004g010t077" - result = Parser.parse_weather(weather) - assert result.wind_gust == 10 - end - - test "handles temperature edge cases" do - # Negative temperature - weather = "_01231559c...s...g...t-05" - result = Parser.parse_weather(weather) - assert result.raw_weather_data =~ "t-05" - - # Missing temperature - weather = "_01231559c...s...g...t..." - result = Parser.parse_weather(weather) - assert result.raw_weather_data =~ "t..." - end - - test "handles humidity edge cases" do - # 100% humidity encoded as 00 - weather = "_01231559c...s...g...t...h00" - result = Parser.parse_weather(weather) - assert result.raw_weather_data =~ "h00" - - # Missing humidity - weather = "_01231559c...s...g...t...h.." - result = Parser.parse_weather(weather) - assert result.raw_weather_data =~ "h.." - assert Map.get(result, :humidity) == nil - end - - test "handles luminosity variations" do - # Lowercase 'l' for values < 1000 - weather = "_01231559c...s...g...t...l456" - result = Parser.parse_weather(weather) - assert result.luminosity == 456 - - # Uppercase 'L' for values >= 1000 - weather = "_01231559c...s...g...t...L999" - result = Parser.parse_weather(weather) - assert result.raw_weather_data =~ "L999" - end - - test "handles software type in weather" do - weather = "_01231559c...s...g...t...wRSW" - result = Parser.parse_weather(weather) - assert Map.get(result, :raw_weather_data) =~ "wRSW" - end - - test "handles missing rain fields" do - weather = "_01231559c...s...g...t...r...p...P..." - result = Parser.parse_weather(weather) - assert Map.get(result, :rain_1h) == nil - assert Map.get(result, :rain_24h) == nil - assert Map.get(result, :rain_since_midnight) == nil - end - end - - describe "parse_object/1 - edge cases" do - test "handles objects with compressed position" do - result = Parser.parse_object(";COMPRESS *092345z/5L!!<*e7>7P[Comment") - assert result.data_type == :object - assert is_nil(result.latitude) - assert is_nil(result.longitude) - end - - test "handles objects with invalid position" do - result = Parser.parse_object(";BADPOS *092345zINVALID_POSITION_DATA") - assert result.data_type == :object - end - end - - describe "parse_manufacturer/1" do - test "with any manufacturer" do - Enum.each( - [ - %{matcher: " " <> <<0, 0>>, result: "Original MIC-E"}, - %{matcher: ">" <> <<0>> <> "^", result: "Kenwood TH-D74"}, - %{matcher: ">" <> <<0, 0>>, result: "Kenwood TH-D74A"}, - %{matcher: "]" <> <<0>> <> "=", result: "Kenwood DM-710"}, - %{matcher: "]" <> <<0, 0>>, result: "Kenwood DM-700"}, - %{matcher: "`_" <> " ", result: "Yaesu VX-8"}, - %{matcher: "`_" <> "\"", result: "Yaesu FTM-350"}, - %{matcher: "`_" <> "#", result: "Yaesu VX-8G"}, - %{matcher: "`_" <> "$", result: "Yaesu FT1D"}, - %{matcher: "`_" <> "%", result: "Yaesu FTM-400DR"}, - %{matcher: "`_" <> ")", result: "Yaesu FTM-100D"}, - %{matcher: "`_" <> "(", result: "Yaesu FT2D"}, - %{matcher: "` X", result: "AP510"}, - %{matcher: "`" <> <<0, 0>>, result: "Mic-Emsg"}, - %{matcher: "'|3", result: "Byonics TinyTrack3"}, - %{matcher: "'|4", result: "Byonics TinyTrack4"}, - %{matcher: "':4", result: "SCS GmbH & Co. P4dragon DR-7400 modems"}, - %{matcher: "':8", result: "SCS GmbH & Co. P4dragon DR-7800 modems"}, - %{matcher: "'" <> <<0, 0>>, result: "McTrackr"}, - %{matcher: <<0>> <> "\"" <> <<0>>, result: "Hamhud"}, - %{matcher: <<0>> <> "/" <> <<0>>, result: "Argent"}, - %{matcher: <<0>> <> "^" <> <<0>>, result: "HinzTec anyfrog"}, - %{ - matcher: <<0>> <> "*" <> <<0>>, - result: "APOZxx www.KissOZ.dk Tracker. OZ1EKD and OZ7HVO" - }, - %{matcher: <<0>> <> "~" <> <<0>>, result: "Other"}, - %{matcher: <<0, 0, 0>>, result: "Unknown"} - ], - fn %{matcher: symbols, result: result} -> - assert Parser.parse_manufacturer(symbols) == result - end - ) - end - end - - describe "parse_mic_e/2" do - test "with valid mic-e" do - # `|J!l4^\k/]"6?}= - mic_e_position = <<96, 124, 74, 33, 108, 52, 94, 107, 47, 93, 34, 54, 63, 125, 61>> - assert %MicE{} = Parser.parse_mic_e("SS0Y1S", mic_e_position) - end - end - - describe "parse_mic_e_destination/1" do - test "1" do - assert Parser.parse_mic_e_destination("SS0Y1S") == %{ - lat_degrees: 33, - lat_direction: :north, - lat_fractional: 13, - lat_minutes: 9, - lon_direction: :west, - longitude_offset: 0, - message_code: "M01", - message_description: "En Route" - } - end - - test "2" do - assert Parser.parse_mic_e_destination("SS08LL") == %{ - lat_degrees: 33, - lat_direction: :south, - lat_fractional: 0, - lat_minutes: 8, - lon_direction: :east, - longitude_offset: 0, - message_code: "M01", - message_description: "En Route" - } - end - - test "3" do - assert Parser.parse_mic_e_destination("SS0L0A") == %{ - lat_degrees: 33, - lat_direction: :south, - lat_fractional: 0, - lat_minutes: 0, - lon_direction: :unknown, - longitude_offset: 0, - message_code: "M01", - message_description: "En Route" - } - end - - test "4" do - assert Parser.parse_mic_e_destination("SS0AA3") == %{ - lat_degrees: 33, - lat_direction: :unknown, - lat_fractional: 3, - lat_minutes: 0, - lon_direction: :east, - longitude_offset: :unknown, - message_code: "M01", - message_description: "En Route" - } - end - end - - describe "NMEA sentence parsing" do - test "parses GPRMC sentences" do - packet = "W5ISP>APRS:$GPRMC,123456,A,4903.50,N,07201.75,W*6A" - assert {:ok, result} = Parser.parse(packet) - assert result.data_extended.latitude == nil - assert result.data_extended.longitude == nil - end - - test "parses GPGGA sentences" do - packet = "W5ISP>APRS:$GPGGA,123456,4903.50,N,07201.75,W,1,08,0.9,545.4,M,46.9,M,,*47" - assert {:ok, result} = Parser.parse(packet) - assert result.data_extended.latitude == nil - assert result.data_extended.longitude == nil - end - - test "handles invalid NMEA sentences" do - # Invalid format - packet = "W5ISP>APRS:INVALID" - assert {:ok, result} = Parser.parse(packet) - assert result.data_type == :unknown_datatype - - # Missing fields - packet = "W5ISP>APRS:$GPRMC,123456,A,4903.50,N,07201.75,W*6A" - assert {:ok, result} = Parser.parse(packet) - assert result.data_type == :raw_gps_ultimeter - assert result.data_extended.error == "Invalid NMEA sentence format" - end - end - - describe "KISS/TNC2 conversion" do - test "converts KISS to TNC2" do - kiss = <<0xC0, 0x00, "W5ISP>APRS:>Test", 0xC0>> - tnc2 = Parser.kiss_to_tnc2(kiss) - assert tnc2 == "W5ISP>APRS:>Test" - end - - test "converts TNC2 to KISS" do - tnc2 = "W5ISP>APRS:>Test" - kiss = Parser.tnc2_to_kiss(tnc2) - assert byte_size(kiss) > 0 - assert binary_part(kiss, 0, 2) == <<0xC0, 0x00>> - assert binary_part(kiss, byte_size(kiss) - 1, 1) == <<0xC0>> - end - - test "handles invalid KISS frames" do - assert Parser.kiss_to_tnc2(<<0xC0>>) == %{ - error_code: :packet_invalid, - error_message: "Unknown error" - } - end - end - - describe "position ambiguity" do - test "handles position ambiguity in uncompressed positions" do - # No ambiguity - packet = "W5ISP>APRS:!4903.50N/07201.75W>" - assert {:ok, result} = Parser.parse(packet) - assert result.data_extended.position_ambiguity == 0 - - # 1/60th degree ambiguity - packet = "W5ISP>APRS:!4903.5 N/07201.7 W>" - assert {:ok, result} = Parser.parse(packet) - assert result.data_extended.position_ambiguity == 1 - - # 1/10th degree ambiguity - packet = "W5ISP>APRS:!4903. N/07201. W>" - assert {:ok, result} = Parser.parse(packet) - assert result.data_extended.position_ambiguity == 2 - - # 1 degree ambiguity - packet = "W5ISP>APRS:!490 . N/0720 . W>" - assert {:ok, result} = Parser.parse(packet) - assert result.data_extended.position_ambiguity == 3 - - # 10 degree ambiguity - packet = "W5ISP>APRS:!49 . N/072 . W>" - assert {:ok, result} = Parser.parse(packet) - assert result.data_extended.position_ambiguity == 4 - end - - test "handles position ambiguity in compressed positions" do - # No ambiguity - packet = "W5ISP>APRS:/ABCDWXYZs!0 " - assert Parser.parse(packet) == {:error, :invalid_packet} - - # 1/60th degree ambiguity - packet = "W5ISP>APRS:/ABCDWXYZs!0!" - assert Parser.parse(packet) == {:error, :invalid_packet} - - # 1/10th degree ambiguity - packet = "W5ISP>APRS:/ABCDWXYZs!0\"" - assert Parser.parse(packet) == {:error, :invalid_packet} - - # 1 degree ambiguity - packet = "W5ISP>APRS:/ABCDWXYZs!0#" - assert Parser.parse(packet) == {:error, :invalid_packet} - - # 10 degree ambiguity - packet = "W5ISP>APRS:/ABCDWXYZs!0$" - assert Parser.parse(packet) == {:error, :invalid_packet} - end - end - - describe "DAO extension parsing" do - test "parses DAO extension in position" do - packet = "W5ISP>APRS:!4903.50N/07201.75W>!WXY!Comment" - assert {:ok, result} = Parser.parse(packet) - assert result.data_extended.dao.lat_dao == "W" - assert result.data_extended.dao.lon_dao == "X" - assert result.data_extended.dao.datum == "WGS84" - end - - test "handles missing DAO extension" do - packet = "W5ISP>APRS:!4903.50N/07201.75W>No DAO here" - assert {:ok, result} = Parser.parse(packet) - assert result.data_extended.dao == nil - end - end - - describe "network tunneling and third-party traffic" do - test "parses nested tunnels" do - packet = "W5ISP>APRS:}W5ISP>APRS:>Nested" - assert {:ok, result} = Parser.parse(packet) - assert result.data_type == :third_party_traffic - assert result.data_extended.third_party_packet != nil - end - - test "handles maximum tunnel depth" do - packet = "W5ISP>APRS:}}}}W5ISP>APRS:>Too deep" - assert {:ok, result} = Parser.parse(packet) - assert result.data_type == :third_party_traffic - assert result.data_extended.error != nil - end - - test "parses third-party traffic with invalid packet" do - packet = "W5ISP>APRS:}INVALID" - assert {:ok, result} = Parser.parse(packet) - assert result.data_type == :third_party_traffic - assert result.data_extended.error != nil - end - end - - describe "PHG data parsing" do - test "parses PHG data" do - packet = "W5ISP>APRS:#PHG2360" - assert {:ok, result} = Parser.parse(packet) - assert result.data_type == :phg_data - assert result.data_extended.power == {4, "4 watts"} - assert result.data_extended.height == {80, "80 feet"} - assert result.data_extended.gain == {6, "6 dB"} - assert result.data_extended.directivity == {360, "Omni"} - end - - test "parses DFS data" do - packet = "W5ISP>APRS:#DFS2360" - assert {:ok, result} = Parser.parse(packet) - assert result.data_type == :df_report - assert result.data_extended.df_strength == {2, "6 dB above S0"} - assert result.data_extended.height == {80, "80 feet"} - assert result.data_extended.gain == {6, "6 dB"} - assert result.data_extended.directivity == {360, "Omni"} - end - - test "handles invalid PHG data" do - packet = "W5ISP>APRS:#PHGXXXX" - assert {:ok, result} = Parser.parse(packet) - assert result.data_type == :phg_data - assert result.data_extended.power == {nil, "Unknown power: X"} - assert result.data_extended.height == {nil, "Unknown height: X"} - assert result.data_extended.gain == {nil, "Unknown gain: X"} - assert result.data_extended.directivity == {nil, "Unknown directivity: X"} - end - end - - describe "compressed position parsing" do - test "parses compressed position with course/speed" do - packet = "W5ISP>APRS:/5L!!<*e7>7P[" - assert Parser.parse(packet) == {:error, :invalid_packet} - end - - test "parses compressed position with range" do - packet = "W5ISP>APRS:/5L!!<*e7>Z[" - assert Parser.parse(packet) == {:error, :invalid_packet} - end - - test "handles invalid compressed position" do - packet = "W5ISP>APRS:/INVALID" - assert Parser.parse(packet) == {:error, :invalid_packet} - end - end - - describe "error handling" do - test "handles invalid AX.25 callsigns" do - assert {:error, %{error_code: :srccall_badchars}} = Parser.parse("INVALID*>APRS:>Test") - packet = "W5ISP>INVALID:>Test" - assert {:ok, result} = Parser.parse(packet) - assert result.data_type == :status - assert result.data_extended.status_text == "Test" - assert result.destination == "INVALID" - end - - test "handles invalid path components" do - assert {:error, %{error_code: :dstpath_toomany}} = - Parser.parse("W5ISP>APRS,TOO,MANY,DIGIS:>Test") - end - - test "handles missing destination" do - assert Parser.parse("W5ISP>:>Test") == {:error, "Invalid packet format"} - end - end -end diff --git a/test/parser/phg_test.exs b/test/parser/phg_test.exs new file mode 100644 index 0000000..13fbdaa --- /dev/null +++ b/test/parser/phg_test.exs @@ -0,0 +1,12 @@ +defmodule Parser.PHGTest do + use ExUnit.Case, async: true + + alias Parser.PHG + alias Parser.Types.ParseError + + describe "parse/1" do + test "returns not_implemented error for now" do + assert %ParseError{error_code: :not_implemented} = PHG.parse("#PHG2360") + end + end +end diff --git a/test/parser/utils_test.exs b/test/parser/utils_test.exs new file mode 100644 index 0000000..12f7e76 --- /dev/null +++ b/test/parser/utils_test.exs @@ -0,0 +1,5 @@ +defmodule Parser.UtilsTest do + use ExUnit.Case, async: true + + # Placeholder for future utility function tests +end