parser update
This commit is contained in:
parent
e9002426a7
commit
76e93aba17
23 changed files with 480 additions and 927 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
37
lib/parser/ax25.ex
Normal file
37
lib/parser/ax25.ex
Normal file
|
|
@ -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
|
||||
20
lib/parser/compressed.ex
Normal file
20
lib/parser/compressed.ex
Normal file
|
|
@ -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
|
||||
22
lib/parser/core.ex
Normal file
22
lib/parser/core.ex
Normal file
|
|
@ -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
|
||||
14
lib/parser/item.ex
Normal file
14
lib/parser/item.ex
Normal file
|
|
@ -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
|
||||
14
lib/parser/message.ex
Normal file
14
lib/parser/message.ex
Normal file
|
|
@ -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
|
||||
20
lib/parser/nmea.ex
Normal file
20
lib/parser/nmea.ex
Normal file
|
|
@ -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
|
||||
14
lib/parser/object.ex
Normal file
14
lib/parser/object.ex
Normal file
|
|
@ -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
|
||||
20
lib/parser/phg.ex
Normal file
20
lib/parser/phg.ex
Normal file
|
|
@ -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
|
||||
106
lib/parser/position.ex
Normal file
106
lib/parser/position.ex
Normal file
|
|
@ -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
|
||||
<<lat::binary-size(8), sym_table_id::binary-size(1), lon::binary-size(9), sym_code::binary-size(1),
|
||||
comment::binary>> ->
|
||||
%{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
|
||||
14
lib/parser/status.ex
Normal file
14
lib/parser/status.ex
Normal file
|
|
@ -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
|
||||
14
lib/parser/telemetry.ex
Normal file
14
lib/parser/telemetry.ex
Normal file
|
|
@ -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
|
||||
14
lib/parser/timestamped_position.ex
Normal file
14
lib/parser/timestamped_position.ex
Normal file
|
|
@ -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
|
||||
84
lib/parser/types.ex
Normal file
84
lib/parser/types.ex
Normal file
|
|
@ -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
|
||||
7
lib/parser/utils.ex
Normal file
7
lib/parser/utils.ex
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
defmodule Parser.Utils do
|
||||
@moduledoc """
|
||||
Shared helper functions for APRS parser modules.
|
||||
"""
|
||||
|
||||
# Add utility functions here as needed
|
||||
end
|
||||
14
lib/parser/weather.ex
Normal file
14
lib/parser/weather.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
||||
<<latitude::binary-size(8), lat_direction::binary>> = aprs_latitude
|
||||
<<longitude::binary-size(8), lon_direction::binary>> = aprs_longitude
|
||||
|
||||
<<lat_deg::binary-size(3), lat_min::binary-size(2), lat_fractional::binary-size(3)>> =
|
||||
convert_garbage_to_zero(latitude)
|
||||
|
||||
<<lon_deg::binary-size(3), lon_min::binary-size(2), lon_fractional::binary-size(3)>> =
|
||||
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
|
||||
12
test/parser/compressed_test.exs
Normal file
12
test/parser/compressed_test.exs
Normal file
|
|
@ -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
|
||||
12
test/parser/core_test.exs
Normal file
12
test/parser/core_test.exs
Normal file
|
|
@ -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
|
||||
13
test/parser/nmea_test.exs
Normal file
13
test/parser/nmea_test.exs
Normal file
|
|
@ -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
|
||||
|
|
@ -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:<IGATE,MSG_CNT=30")
|
||||
assert packet.data_type == :station_capabilities
|
||||
|
||||
# Test query
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>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
|
||||
12
test/parser/phg_test.exs
Normal file
12
test/parser/phg_test.exs
Normal file
|
|
@ -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
|
||||
5
test/parser/utils_test.exs
Normal file
5
test/parser/utils_test.exs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
defmodule Parser.UtilsTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
# Placeholder for future utility function tests
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue