diff --git a/lib/parser.ex b/lib/parser.ex index 496892d..2c4ddee 100644 --- a/lib/parser.ex +++ b/lib/parser.ex @@ -271,12 +271,13 @@ defmodule Parser do def parse_data(:query, _destination, data), do: parse_query(data) def parse_data(:user_defined, _destination, data), do: parse_user_defined(data) def parse_data(:third_party_traffic, _destination, data), do: parse_third_party_traffic(data) - def parse_data(:peet_logging, _destination, data), do: Parser.Helpers.parse_peet_logging(data) - def parse_data(:invalid_test_data, _destination, data), do: Parser.Helpers.parse_invalid_test_data(data) + def parse_data(:peet_logging, _destination, data), do: Parser.SpecialDataHelpers.parse_peet_logging(data) + + def parse_data(:invalid_test_data, _destination, data), do: Parser.SpecialDataHelpers.parse_invalid_test_data(data) def parse_data(:raw_gps_ultimeter, _destination, data) do - case Parser.Helpers.parse_nmea_sentence(data) do + case Parser.NMEAHelpers.parse_nmea_sentence(data) do {:error, error} -> %{ data_type: :raw_gps_ultimeter, @@ -294,10 +295,10 @@ defmodule Parser do <<"DFS", s, h, g, d, rest::binary>> = data %{ - df_strength: Parser.Helpers.parse_df_strength(s), - height: Parser.Helpers.parse_phg_height(h), - gain: Parser.Helpers.parse_phg_gain(g), - directivity: Parser.Helpers.parse_phg_directivity(d), + df_strength: Parser.PHGHelpers.parse_df_strength(s), + height: Parser.PHGHelpers.parse_phg_height(h), + gain: Parser.PHGHelpers.parse_phg_gain(g), + directivity: Parser.PHGHelpers.parse_phg_directivity(d), comment: rest, data_type: :df_report } @@ -359,8 +360,8 @@ defmodule Parser do <<"/", latitude::binary-size(4), longitude::binary-size(4), symbol_code::binary-size(1), _cs::binary-size(2), _compression_type::binary-size(2), _rest::binary>> ) do - lat = Parser.Helpers.convert_to_base91(latitude) - lon = Parser.Helpers.convert_to_base91(longitude) + lat = Parser.CompressedPositionHelpers.convert_to_base91(latitude) + lon = Parser.CompressedPositionHelpers.convert_to_base91(longitude) %{ latitude: lat, @@ -414,7 +415,7 @@ defmodule Parser do defp parse_position_uncompressed(latitude, sym_table_id, longitude, symbol_code, comment) do %{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude) - ambiguity = Parser.Helpers.calculate_position_ambiguity(latitude, longitude) + ambiguity = Parser.UtilityHelpers.calculate_position_ambiguity(latitude, longitude) dao_data = parse_dao_extension(comment) {course, speed} = extract_course_and_speed(comment) @@ -448,7 +449,7 @@ defmodule Parser do defp parse_position_short_uncompressed(latitude, sym_table_id, longitude) do %{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude) - ambiguity = Parser.Helpers.calculate_position_ambiguity(latitude, longitude) + ambiguity = Parser.UtilityHelpers.calculate_position_ambiguity(latitude, longitude) has_position = (is_number(lat) or is_struct(lat, Decimal)) and (is_number(lon) or is_struct(lon, Decimal)) @@ -471,10 +472,10 @@ defmodule Parser do end defp parse_position_compressed(latitude_compressed, longitude_compressed, symbol_code, cs, compression_type, comment) do - converted_lat = Parser.Helpers.convert_compressed_lat(latitude_compressed) - converted_lon = Parser.Helpers.convert_compressed_lon(longitude_compressed) - compressed_cs = Parser.Helpers.convert_compressed_cs(cs) - ambiguity = Parser.Helpers.calculate_compressed_ambiguity(compression_type) + converted_lat = Parser.CompressedPositionHelpers.convert_compressed_lat(latitude_compressed) + converted_lon = Parser.CompressedPositionHelpers.convert_compressed_lon(longitude_compressed) + compressed_cs = Parser.CompressedPositionHelpers.convert_compressed_cs(cs) + ambiguity = Parser.CompressedPositionHelpers.calculate_compressed_ambiguity(compression_type) has_position = (is_number(converted_lat) or is_struct(converted_lat, Decimal)) and @@ -508,7 +509,7 @@ defmodule Parser do compression_type: compression_type, data_type: :position, compressed?: true, - position_ambiguity: Parser.Helpers.calculate_compressed_ambiguity(compression_type), + position_ambiguity: Parser.CompressedPositionHelpers.calculate_compressed_ambiguity(compression_type), dao: nil, course: nil, speed: nil, @@ -549,7 +550,7 @@ defmodule Parser do symbol_code::binary-size(1), comment::binary>>, _data_type ) do - case Parser.Helpers.validate_position_data(latitude, longitude) do + case Parser.UtilityHelpers.validate_position_data(latitude, longitude) do {:ok, {lat, lon}} -> position = parse_aprs_position(latitude, longitude) {course, speed} = extract_course_and_speed(comment) @@ -558,7 +559,7 @@ defmodule Parser do latitude: lat, longitude: lon, position: position, - time: Parser.Helpers.validate_timestamp(time), + time: Parser.UtilityHelpers.validate_timestamp(time), symbol_table_id: sym_table_id, symbol_code: symbol_code, comment: comment, @@ -726,11 +727,14 @@ defmodule Parser do <<"/", latitude_compressed::binary-size(4), longitude_compressed::binary-size(4), symbol_code::binary-size(1), cs::binary-size(2), compression_type::binary-size(1), comment::binary>> -> try do - converted_lat = Parser.Helpers.convert_compressed_lat(latitude_compressed) - converted_lon = Parser.Helpers.convert_compressed_lon(longitude_compressed) + converted_lat = + Parser.CompressedPositionHelpers.convert_compressed_lat(latitude_compressed) + + converted_lon = + Parser.CompressedPositionHelpers.convert_compressed_lon(longitude_compressed) if is_number(converted_lat) and is_number(converted_lon) do - compressed_cs = Parser.Helpers.convert_compressed_cs(cs) + compressed_cs = Parser.CompressedPositionHelpers.convert_compressed_cs(cs) base_data = %{ latitude: converted_lat, @@ -826,9 +830,13 @@ defmodule Parser do # Compressed position format <<"/", latitude_compressed::binary-size(4), longitude_compressed::binary-size(4), symbol_code::binary-size(1), cs::binary-size(2), compression_type::binary-size(1), comment::binary>> -> - converted_lat = Parser.Helpers.convert_compressed_lat(latitude_compressed) - converted_lon = Parser.Helpers.convert_compressed_lon(longitude_compressed) - compressed_cs = Parser.Helpers.convert_compressed_cs(cs) + converted_lat = + Parser.CompressedPositionHelpers.convert_compressed_lat(latitude_compressed) + + converted_lon = + Parser.CompressedPositionHelpers.convert_compressed_lon(longitude_compressed) + + compressed_cs = Parser.CompressedPositionHelpers.convert_compressed_cs(cs) base_data = %{ latitude: converted_lat, @@ -878,22 +886,22 @@ defmodule Parser do # Comprehensive weather data parsing defp parse_weather_data(weather_data) do # Extract timestamp if present - timestamp = Parser.Helpers.extract_timestamp(weather_data) - weather_data = Parser.Helpers.remove_timestamp(weather_data) + timestamp = Parser.WeatherHelpers.extract_timestamp(weather_data) + weather_data = Parser.WeatherHelpers.remove_timestamp(weather_data) # Parse each weather component weather_values = %{ - wind_direction: Parser.Helpers.parse_wind_direction(weather_data), - wind_speed: Parser.Helpers.parse_wind_speed(weather_data), - wind_gust: Parser.Helpers.parse_wind_gust(weather_data), - temperature: Parser.Helpers.parse_temperature(weather_data), - rain_1h: Parser.Helpers.parse_rainfall_1h(weather_data), - rain_24h: Parser.Helpers.parse_rainfall_24h(weather_data), - rain_since_midnight: Parser.Helpers.parse_rainfall_since_midnight(weather_data), - humidity: Parser.Helpers.parse_humidity(weather_data), - pressure: Parser.Helpers.parse_pressure(weather_data), - luminosity: Parser.Helpers.parse_luminosity(weather_data), - snow: Parser.Helpers.parse_snow(weather_data) + wind_direction: Parser.WeatherHelpers.parse_wind_direction(weather_data), + wind_speed: Parser.WeatherHelpers.parse_wind_speed(weather_data), + wind_gust: Parser.WeatherHelpers.parse_wind_gust(weather_data), + temperature: Parser.WeatherHelpers.parse_temperature(weather_data), + rain_1h: Parser.WeatherHelpers.parse_rainfall_1h(weather_data), + rain_24h: Parser.WeatherHelpers.parse_rainfall_24h(weather_data), + rain_since_midnight: Parser.WeatherHelpers.parse_rainfall_since_midnight(weather_data), + humidity: Parser.WeatherHelpers.parse_humidity(weather_data), + pressure: Parser.WeatherHelpers.parse_pressure(weather_data), + luminosity: Parser.WeatherHelpers.parse_luminosity(weather_data), + snow: Parser.WeatherHelpers.parse_snow(weather_data) } # Build base result map @@ -917,9 +925,9 @@ defmodule Parser do digital_values = values |> Enum.drop(5) |> Enum.take(8) %{ - sequence_number: Parser.Helpers.parse_telemetry_sequence(seq), - analog_values: Parser.Helpers.parse_analog_values(analog_values), - digital_values: Parser.Helpers.parse_digital_values(digital_values), + sequence_number: Parser.TelemetryHelpers.parse_telemetry_sequence(seq), + analog_values: Parser.TelemetryHelpers.parse_analog_values(analog_values), + digital_values: Parser.TelemetryHelpers.parse_digital_values(digital_values), data_type: :telemetry, raw_data: rest } @@ -949,9 +957,9 @@ defmodule Parser do |> Enum.chunk_every(3) |> Enum.map(fn [a, b, c] -> %{ - a: Parser.Helpers.parse_coefficient(a), - b: Parser.Helpers.parse_coefficient(b), - c: Parser.Helpers.parse_coefficient(c) + a: Parser.TelemetryHelpers.parse_coefficient(a), + b: Parser.TelemetryHelpers.parse_coefficient(b), + c: Parser.TelemetryHelpers.parse_coefficient(c) } end) @@ -1065,7 +1073,7 @@ defmodule Parser do # Third Party Traffic parsing def parse_third_party_traffic(packet) do - if Parser.Helpers.count_leading_braces(packet) + 1 > 3 do + if Parser.UtilityHelpers.count_leading_braces(packet) + 1 > 3 do %{ error: "Maximum tunnel depth exceeded" } @@ -1244,10 +1252,10 @@ defmodule Parser do def parse_phg_data(<<"PHG", p, h, g, d, rest::binary>>) do %{ - power: Parser.Helpers.parse_phg_power(p), - height: Parser.Helpers.parse_phg_height(h), - gain: Parser.Helpers.parse_phg_gain(g), - directivity: Parser.Helpers.parse_phg_directivity(d), + power: Parser.PHGHelpers.parse_phg_power(p), + height: Parser.PHGHelpers.parse_phg_height(h), + gain: Parser.PHGHelpers.parse_phg_gain(g), + directivity: Parser.PHGHelpers.parse_phg_directivity(d), comment: rest, data_type: :phg_data } diff --git a/lib/parser/compressed_position_helpers.ex b/lib/parser/compressed_position_helpers.ex new file mode 100644 index 0000000..75ca3ca --- /dev/null +++ b/lib/parser/compressed_position_helpers.ex @@ -0,0 +1,57 @@ +defmodule Parser.CompressedPositionHelpers do + @moduledoc """ + Compressed position helpers for APRS. + """ + + @spec convert_compressed_lat(binary()) :: {:ok, float()} | {:error, String.t()} + def convert_compressed_lat(lat) when is_binary(lat) and byte_size(lat) == 4 do + [l1, l2, l3, l4] = to_charlist(lat) + {:ok, 90 - ((l1 - 33) * 91 ** 3 + (l2 - 33) * 91 ** 2 + (l3 - 33) * 91 + l4 - 33) / 380_926} + end + + def convert_compressed_lat(_), do: {:error, "Invalid compressed latitude"} + + @spec convert_compressed_lon(binary()) :: {:ok, float()} | {:error, String.t()} + def convert_compressed_lon(lon) when is_binary(lon) and byte_size(lon) == 4 do + [l1, l2, l3, l4] = to_charlist(lon) + {:ok, -180 + ((l1 - 33) * 91 ** 3 + (l2 - 33) * 91 ** 2 + (l3 - 33) * 91 + l4 - 33) / 190_463} + end + + def convert_compressed_lon(_), do: {:error, "Invalid compressed longitude"} + + @spec calculate_compressed_ambiguity(String.t()) :: integer() + def calculate_compressed_ambiguity(compression_type) do + case compression_type do + " " -> 0 + "!" -> 1 + "\"" -> 2 + "#" -> 3 + "$" -> 4 + _ -> 0 + end + end + + @doc false + def convert_to_base91(<>) do + [v1, v2, v3, v4] = to_charlist(value) + (v1 - 33) * 91 * 91 * 91 + (v2 - 33) * 91 * 91 + (v3 - 33) * 91 + v4 + end + + @spec convert_compressed_cs(binary()) :: map() + def convert_compressed_cs(cs) do + [c, s] = to_charlist(cs) + c = c - 33 + s = s - 33 + + case c do + x when x in ?!..?z -> + %{course: s * 4, speed: Aprs.Convert.speed(1.08 ** s - 1, :knots, :mph)} + + ?Z -> + %{range: 2 * 1.08 ** s} + + _ -> + %{} + end + end +end diff --git a/lib/parser/helpers.ex b/lib/parser/helpers.ex index 48e063e..80f7cba 100644 --- a/lib/parser/helpers.ex +++ b/lib/parser/helpers.ex @@ -1,492 +1,14 @@ defmodule Parser.Helpers do @moduledoc """ - Public helper functions for APRS parsing (NMEA, PHG/DF, compressed position, etc). + This module has been split into domain-specific helpers: + - Parser.NMEAHelpers + - Parser.PHGHelpers + - Parser.CompressedPositionHelpers + - Parser.KISSHelpers + - Parser.TelemetryHelpers + - Parser.WeatherHelpers + - Parser.UtilityHelpers + Update your code to use the new modules directly. """ - - import Decimal, only: [new: 1, add: 2, negate: 1] - - @type phg_power :: {integer() | nil, String.t()} - @type phg_height :: {integer() | nil, String.t()} - @type phg_gain :: {integer() | nil, String.t()} - @type phg_directivity :: {integer() | nil, String.t()} - @type df_strength :: {integer() | nil, String.t()} - - # NMEA helpers - @spec parse_nmea_coordinate(String.t(), String.t()) :: {:ok, float()} | {:error, String.t()} - def parse_nmea_coordinate(value, direction) when is_binary(value) and is_binary(direction) do - case Float.parse(value) do - {coord, _} -> - coord = coord / 100.0 - coord = apply_nmea_direction(coord, direction) - handle_coordinate_result(coord) - - :error -> - {:error, "Invalid coordinate value"} - end - end - - @spec parse_nmea_coordinate(any(), any()) :: {:error, String.t()} - def parse_nmea_coordinate(_, _), do: {:error, "Invalid coordinate format"} - - defp handle_coordinate_result(coord) when is_tuple(coord), do: coord - defp handle_coordinate_result(coord), do: {:ok, coord} - - defp apply_nmea_direction(coord, direction) do - case direction do - "N" -> coord - "S" -> -coord - "E" -> coord - "W" -> -coord - _ -> {:error, "Invalid coordinate direction"} - end - end - - # PHG/DF helpers - @spec parse_phg_power(integer()) :: phg_power - def parse_phg_power(?0), do: {1, "1 watt"} - def parse_phg_power(?1), do: {4, "4 watts"} - def parse_phg_power(?2), do: {9, "9 watts"} - def parse_phg_power(?3), do: {16, "16 watts"} - def parse_phg_power(?4), do: {25, "25 watts"} - def parse_phg_power(?5), do: {36, "36 watts"} - def parse_phg_power(?6), do: {49, "49 watts"} - def parse_phg_power(?7), do: {64, "64 watts"} - def parse_phg_power(?8), do: {81, "81 watts"} - def parse_phg_power(?9), do: {81, "81 watts"} - def parse_phg_power(p), do: {nil, "Unknown power: #{<

>}"} - - @spec parse_phg_height(integer()) :: phg_height - def parse_phg_height(?0), do: {10, "10 feet"} - def parse_phg_height(?1), do: {20, "20 feet"} - def parse_phg_height(?2), do: {40, "40 feet"} - def parse_phg_height(?3), do: {80, "80 feet"} - def parse_phg_height(?4), do: {160, "160 feet"} - def parse_phg_height(?5), do: {320, "320 feet"} - def parse_phg_height(?6), do: {640, "640 feet"} - def parse_phg_height(?7), do: {1280, "1280 feet"} - def parse_phg_height(?8), do: {2560, "2560 feet"} - def parse_phg_height(?9), do: {5120, "5120 feet"} - def parse_phg_height(h), do: {nil, "Unknown height: #{<>}"} - - @spec parse_phg_gain(integer()) :: phg_gain - def parse_phg_gain(?0), do: {0, "0 dB"} - def parse_phg_gain(?1), do: {1, "1 dB"} - def parse_phg_gain(?2), do: {2, "2 dB"} - def parse_phg_gain(?3), do: {3, "3 dB"} - def parse_phg_gain(?4), do: {4, "4 dB"} - def parse_phg_gain(?5), do: {5, "5 dB"} - def parse_phg_gain(?6), do: {6, "6 dB"} - def parse_phg_gain(?7), do: {7, "7 dB"} - def parse_phg_gain(?8), do: {8, "8 dB"} - def parse_phg_gain(?9), do: {9, "9 dB"} - def parse_phg_gain(g), do: {nil, "Unknown gain: #{<>}"} - - @spec parse_phg_directivity(integer()) :: phg_directivity - def parse_phg_directivity(?0), do: {360, "Omni"} - def parse_phg_directivity(?1), do: {45, "45° NE"} - def parse_phg_directivity(?2), do: {90, "90° E"} - def parse_phg_directivity(?3), do: {135, "135° SE"} - def parse_phg_directivity(?4), do: {180, "180° S"} - def parse_phg_directivity(?5), do: {225, "225° SW"} - def parse_phg_directivity(?6), do: {270, "270° W"} - def parse_phg_directivity(?7), do: {315, "315° NW"} - def parse_phg_directivity(?8), do: {360, "360° N"} - def parse_phg_directivity(?9), do: {nil, "Undefined"} - def parse_phg_directivity(d), do: {nil, "Unknown directivity: #{<>}"} - - @spec parse_df_strength(integer()) :: df_strength - def parse_df_strength(?0), do: {0, "0 dB"} - def parse_df_strength(?1), do: {1, "3 dB above S0"} - def parse_df_strength(?2), do: {2, "6 dB above S0"} - def parse_df_strength(?3), do: {3, "9 dB above S0"} - def parse_df_strength(?4), do: {4, "12 dB above S0"} - def parse_df_strength(?5), do: {5, "15 dB above S0"} - def parse_df_strength(?6), do: {6, "18 dB above S0"} - def parse_df_strength(?7), do: {7, "21 dB above S0"} - def parse_df_strength(?8), do: {8, "24 dB above S0"} - def parse_df_strength(?9), do: {9, "27 dB above S0"} - def parse_df_strength(s), do: {nil, "Unknown strength: #{<>}"} - - # Compressed position helpers - @spec convert_compressed_lat(binary()) :: {:ok, float()} | {:error, String.t()} - def convert_compressed_lat(lat) when is_binary(lat) and byte_size(lat) == 4 do - [l1, l2, l3, l4] = to_charlist(lat) - {:ok, 90 - ((l1 - 33) * 91 ** 3 + (l2 - 33) * 91 ** 2 + (l3 - 33) * 91 + l4 - 33) / 380_926} - end - - def convert_compressed_lat(_), do: {:error, "Invalid compressed latitude"} - - @spec convert_compressed_lon(binary()) :: {:ok, float()} | {:error, String.t()} - def convert_compressed_lon(lon) when is_binary(lon) and byte_size(lon) == 4 do - [l1, l2, l3, l4] = to_charlist(lon) - {:ok, -180 + ((l1 - 33) * 91 ** 3 + (l2 - 33) * 91 ** 2 + (l3 - 33) * 91 + l4 - 33) / 190_463} - end - - def convert_compressed_lon(_), do: {:error, "Invalid compressed longitude"} - - # PEET Logging parsing - @spec parse_peet_logging(binary()) :: map() - def parse_peet_logging(<<"*", peet_data::binary>>) do - %{ - peet_data: peet_data, - data_type: :peet_logging - } - end - - @spec parse_peet_logging(any()) :: map() - def parse_peet_logging(data) do - %{ - peet_data: data, - data_type: :peet_logging - } - end - - # Invalid/Test Data parsing - @spec parse_invalid_test_data(binary()) :: map() - def parse_invalid_test_data(<<",", test_data::binary>>) do - %{ - test_data: test_data, - data_type: :invalid_test_data - } - end - - @spec parse_invalid_test_data(any()) :: map() - def parse_invalid_test_data(data) do - %{ - test_data: data, - data_type: :invalid_test_data - } - end - - # NMEA sentence parsing (minimal public wrapper) - @spec parse_nmea_sentence(any()) :: {:error, String.t()} - def parse_nmea_sentence(_sentence) do - # This is a minimal wrapper; full NMEA parsing logic can be implemented as needed - {:error, "NMEA parsing not implemented"} - end - - # KISS to TNC2 conversion - @spec kiss_to_tnc2(binary()) :: binary() - def kiss_to_tnc2(<<0xC0, 0x00, rest::binary>>) do - # Remove KISS framing and control byte - tnc2 = - rest - |> String.trim_trailing(<<0xC0>>) - |> String.replace(<<0xDB, 0xDC>>, <<0xC0>>) - |> String.replace(<<0xDB, 0xDD>>, <<0xDB>>) - - tnc2 - end - - @spec kiss_to_tnc2(any()) :: map() - def kiss_to_tnc2(_), do: %{error_code: :packet_invalid, error_message: "Unknown error"} - - # TNC2 to KISS conversion - @spec tnc2_to_kiss(binary()) :: binary() - def tnc2_to_kiss(tnc2) do - # Add KISS framing and escape special bytes - escaped = - tnc2 - |> String.replace(<<0xDB>>, <<0xDB, 0xDD>>) - |> String.replace(<<0xC0>>, <<0xDB, 0xDC>>) - - <<0xC0, 0x00>> <> escaped <> <<0xC0>> - end - - # Telemetry helpers - @spec parse_telemetry_sequence(String.t()) :: integer() | nil - def parse_telemetry_sequence(seq) do - case Integer.parse(seq) do - {num, _} -> num - :error -> nil - end - end - - def parse_analog_values(values) do - Enum.map(values, &parse_analog_value/1) - end - - defp parse_analog_value("") do - nil - end - - defp parse_analog_value(value) do - case Float.parse(value) do - {float_val, _} -> float_val - :error -> nil - end - end - - def parse_digital_values(values) do - Enum.flat_map(values, &parse_digital_value/1) - end - - defp parse_digital_value("1"), do: [true] - - defp parse_digital_value(value) when is_binary(value) do - value - |> String.graphemes() - |> Enum.map(fn - "1" -> true - "0" -> false - _ -> nil - end) - end - - defp parse_digital_value(_), do: [nil] - - def parse_coefficient(coeff) do - case Float.parse(coeff) do - {float_val, _} -> - float_val - - :error -> - case Integer.parse(coeff) do - {int_val, _} -> int_val - :error -> coeff - end - end - end - - # Weather helpers - @spec extract_timestamp(String.t()) :: String.t() | nil - def extract_timestamp(weather_data) do - case Regex.run(~r/^(\d{6}[hz\/])/, weather_data) do - [_, timestamp] -> timestamp - nil -> nil - end - end - - @spec remove_timestamp(String.t()) :: String.t() - def remove_timestamp(weather_data) do - case Regex.run(~r/^\d{6}[hz\/]/, weather_data) do - [timestamp] -> String.replace(weather_data, timestamp, "") - nil -> weather_data - end - end - - @spec parse_wind_direction(String.t()) :: integer() | nil - def parse_wind_direction(weather_data) do - case Regex.run(~r/(\d{3})\//, weather_data) do - [_, direction] -> String.to_integer(direction) - nil -> nil - end - end - - @spec parse_wind_speed(String.t()) :: integer() | nil - def parse_wind_speed(weather_data) do - case Regex.run(~r/\/(\d{3})/, weather_data) do - [_, speed] -> String.to_integer(speed) - nil -> nil - end - end - - @spec parse_wind_gust(String.t()) :: integer() | nil - def parse_wind_gust(weather_data) do - case Regex.run(~r/g(\d{3})/, weather_data) do - [_, gust] -> String.to_integer(gust) - nil -> nil - end - end - - @spec parse_temperature(String.t()) :: integer() | nil - def parse_temperature(weather_data) do - case Regex.run(~r/t(-?\d{3})/, weather_data) do - [_, temp] -> String.to_integer(temp) - nil -> nil - end - end - - @spec parse_rainfall_1h(String.t()) :: integer() | nil - def parse_rainfall_1h(weather_data) do - case Regex.run(~r/r(\d{3})/, weather_data) do - [_, rain] -> String.to_integer(rain) - nil -> nil - end - end - - @spec parse_rainfall_24h(String.t()) :: integer() | nil - def parse_rainfall_24h(weather_data) do - case Regex.run(~r/p(\d{3})/, weather_data) do - [_, rain] -> String.to_integer(rain) - nil -> nil - end - end - - @spec parse_rainfall_since_midnight(String.t()) :: integer() | nil - def parse_rainfall_since_midnight(weather_data) do - case Regex.run(~r/P(\d{3})/, weather_data) do - [_, rain] -> String.to_integer(rain) - nil -> nil - end - end - - @spec parse_humidity(String.t()) :: integer() | nil - def parse_humidity(weather_data) do - case Regex.run(~r/h(\d{2})/, weather_data) do - [_, humidity] -> - val = String.to_integer(humidity) - normalize_humidity(val) - - nil -> - nil - end - end - - defp normalize_humidity(0), do: 100 - defp normalize_humidity(val), do: val - - @spec parse_pressure(String.t()) :: float() | nil - def parse_pressure(weather_data) do - case Regex.run(~r/b(\d{5})/, weather_data) do - [_, pressure] -> String.to_integer(pressure) / 10.0 - nil -> nil - end - end - - @spec parse_luminosity(String.t()) :: integer() | nil - def parse_luminosity(weather_data) do - case Regex.run(~r/[lL](\d{3})/, weather_data) do - [_, luminosity] -> String.to_integer(luminosity) - nil -> nil - end - end - - @spec parse_snow(String.t()) :: integer() | nil - def parse_snow(weather_data) do - case Regex.run(~r/s(\d{3})/, weather_data) do - [_, snow] -> String.to_integer(snow) - nil -> nil - end - end - - # Ambiguity and utility helpers - @doc false - def count_spaces(str) do - str - |> String.graphemes() - |> Enum.count(fn c -> c == " " end) - end - - @doc false - def count_leading_braces(packet), do: count_leading_braces(packet, 0) - - @doc false - def count_leading_braces(<<"}", rest::binary>>, count), do: count_leading_braces(rest, count + 1) - - @doc false - def count_leading_braces(_packet, count), do: count - - @doc false - def calculate_position_ambiguity(latitude, longitude) do - lat_spaces = count_spaces(latitude) - lon_spaces = count_spaces(longitude) - - Map.get( - %{{0, 0} => 0, {1, 1} => 1, {2, 2} => 2, {3, 3} => 3, {4, 4} => 4}, - {lat_spaces, lon_spaces}, - 0 - ) - end - - @doc false - def calculate_compressed_ambiguity(compression_type) do - case compression_type do - " " -> 0 - "!" -> 1 - "\"" -> 2 - "#" -> 3 - "$" -> 4 - _ -> 0 - end - end - - @doc false - def find_matches(regex, text) do - case Regex.names(regex) do - [] -> - matches = Regex.run(regex, text) - - Enum.reduce(Enum.with_index(matches), %{}, fn {match, index}, acc -> - Map.put(acc, index, match) - end) - - _ -> - Regex.named_captures(regex, text) - end - end - - @doc false - def parse_manufacturer(symbols) do - Aprs.DeviceIdentification.identify_device(symbols) - end - - @doc false - defp apply_latitude_direction(lat_val, "S"), do: negate(lat_val) - defp apply_latitude_direction(lat_val, _), do: lat_val - - @doc false - defp apply_longitude_direction(lon_val, "W"), do: negate(lon_val) - defp apply_longitude_direction(lon_val, _), do: lon_val - - @doc false - def convert_to_base91(<>) do - [v1, v2, v3, v4] = to_charlist(value) - (v1 - 33) * 91 * 91 * 91 + (v2 - 33) * 91 * 91 + (v3 - 33) * 91 + v4 - end - - @spec convert_compressed_cs(binary()) :: map() - def convert_compressed_cs(cs) do - [c, s] = to_charlist(cs) - c = c - 33 - s = s - 33 - - case c do - x when x in ?!..?z -> - %{ - course: s * 4, - speed: Aprs.Convert.speed(1.08 ** s - 1, :knots, :mph) - } - - ?Z -> - %{ - range: 2 * 1.08 ** s - } - - _ -> - %{} - end - end - - @doc false - def validate_position_data(latitude, longitude) do - lat = - case Regex.run(~r/^(\d{2})(\d{2}\.\d+)([NS])$/, latitude) do - [_, degrees, minutes, direction] -> - lat_val = add(new(degrees), Decimal.div(new(minutes), new("60"))) - apply_latitude_direction(lat_val, direction) - - _ -> - nil - end - - lon = - case Regex.run(~r/^(\d{3})(\d{2}\.\d+)([EW])$/, longitude) do - [_, degrees, minutes, direction] -> - lon_val = add(new(degrees), Decimal.div(new(minutes), new("60"))) - apply_longitude_direction(lon_val, direction) - - _ -> - nil - end - - if is_struct(lat, Decimal) and is_struct(lon, Decimal) do - {:ok, {lat, lon}} - else - {:error, :invalid_position} - end - end - - @doc false - def validate_timestamp(_time), do: nil + @compile {:no_warn_undefined, __MODULE__} end diff --git a/lib/parser/item.ex b/lib/parser/item.ex index d16b273..ffb77db 100644 --- a/lib/parser/item.ex +++ b/lib/parser/item.ex @@ -61,9 +61,13 @@ defmodule Parser.Item do <<"/", latitude_compressed::binary-size(4), longitude_compressed::binary-size(4), symbol_code::binary-size(1), cs::binary-size(2), compression_type::binary-size(1), comment::binary>> -> - converted_lat = Parser.Helpers.convert_compressed_lat(latitude_compressed) - converted_lon = Parser.Helpers.convert_compressed_lon(longitude_compressed) - compressed_cs = Parser.Helpers.convert_compressed_cs(cs) + converted_lat = + Parser.CompressedPositionHelpers.convert_compressed_lat(latitude_compressed) + + converted_lon = + Parser.CompressedPositionHelpers.convert_compressed_lon(longitude_compressed) + + compressed_cs = Parser.CompressedPositionHelpers.convert_compressed_cs(cs) base_data = %{ latitude: converted_lat, diff --git a/lib/parser/kiss_helpers.ex b/lib/parser/kiss_helpers.ex new file mode 100644 index 0000000..c4f936e --- /dev/null +++ b/lib/parser/kiss_helpers.ex @@ -0,0 +1,28 @@ +defmodule Parser.KISSHelpers do + @moduledoc """ + KISS/TNC2 conversion helpers for APRS. + """ + + @spec kiss_to_tnc2(binary()) :: binary() | map() + def kiss_to_tnc2(<<0xC0, 0x00, rest::binary>>) do + tnc2 = + rest + |> String.trim_trailing(<<0xC0>>) + |> String.replace(<<0xDB, 0xDC>>, <<0xC0>>) + |> String.replace(<<0xDB, 0xDD>>, <<0xDB>>) + + tnc2 + end + + def kiss_to_tnc2(_), do: %{error_code: :packet_invalid, error_message: "Unknown error"} + + @spec tnc2_to_kiss(binary()) :: binary() + def tnc2_to_kiss(tnc2) do + escaped = + tnc2 + |> String.replace(<<0xDB>>, <<0xDB, 0xDD>>) + |> String.replace(<<0xC0>>, <<0xDB, 0xDC>>) + + <<0xC0, 0x00>> <> escaped <> <<0xC0>> + end +end diff --git a/lib/parser/nmea_helpers.ex b/lib/parser/nmea_helpers.ex new file mode 100644 index 0000000..7e5e002 --- /dev/null +++ b/lib/parser/nmea_helpers.ex @@ -0,0 +1,39 @@ +defmodule Parser.NMEAHelpers do + @moduledoc """ + NMEA coordinate and sentence parsing helpers for APRS. + """ + + @spec parse_nmea_coordinate(String.t(), String.t()) :: {:ok, float()} | {:error, String.t()} + def parse_nmea_coordinate(value, direction) when is_binary(value) and is_binary(direction) do + case Float.parse(value) do + {coord, _} -> + coord = coord / 100.0 + coord = apply_nmea_direction(coord, direction) + handle_coordinate_result(coord) + + :error -> + {:error, "Invalid coordinate value"} + end + end + + @spec parse_nmea_coordinate(any(), any()) :: {:error, String.t()} + def parse_nmea_coordinate(_, _), do: {:error, "Invalid coordinate format"} + + defp handle_coordinate_result(coord) when is_tuple(coord), do: coord + defp handle_coordinate_result(coord), do: {:ok, coord} + + defp apply_nmea_direction(coord, direction) do + case direction do + "N" -> coord + "S" -> -coord + "E" -> coord + "W" -> -coord + _ -> {:error, "Invalid coordinate direction"} + end + end + + @spec parse_nmea_sentence(any()) :: {:error, String.t()} + def parse_nmea_sentence(_sentence) do + {:error, "NMEA parsing not implemented"} + end +end diff --git a/lib/parser/object.ex b/lib/parser/object.ex index 3dd52c5..c021ddb 100644 --- a/lib/parser/object.ex +++ b/lib/parser/object.ex @@ -14,9 +14,13 @@ defmodule Parser.Object do <<"/", latitude_compressed::binary-size(4), longitude_compressed::binary-size(4), symbol_code::binary-size(1), cs::binary-size(2), compression_type::binary-size(1), comment::binary>> -> try do - converted_lat = Parser.Helpers.convert_compressed_lat(latitude_compressed) - converted_lon = Parser.Helpers.convert_compressed_lon(longitude_compressed) - compressed_cs = Parser.Helpers.convert_compressed_cs(cs) + converted_lat = + Parser.CompressedPositionHelpers.convert_compressed_lat(latitude_compressed) + + converted_lon = + Parser.CompressedPositionHelpers.convert_compressed_lon(longitude_compressed) + + compressed_cs = Parser.CompressedPositionHelpers.convert_compressed_cs(cs) base_data = %{ latitude: converted_lat, diff --git a/lib/parser/phg_helpers.ex b/lib/parser/phg_helpers.ex new file mode 100644 index 0000000..fa629b8 --- /dev/null +++ b/lib/parser/phg_helpers.ex @@ -0,0 +1,76 @@ +defmodule Parser.PHGHelpers do + @moduledoc """ + PHG/DF helper functions for APRS. + """ + + @type phg_power :: {integer() | nil, String.t()} + @type phg_height :: {integer() | nil, String.t()} + @type phg_gain :: {integer() | nil, String.t()} + @type phg_directivity :: {integer() | nil, String.t()} + @type df_strength :: {integer() | nil, String.t()} + + @spec parse_phg_power(integer()) :: phg_power + def parse_phg_power(?0), do: {1, "1 watt"} + def parse_phg_power(?1), do: {4, "4 watts"} + def parse_phg_power(?2), do: {9, "9 watts"} + def parse_phg_power(?3), do: {16, "16 watts"} + def parse_phg_power(?4), do: {25, "25 watts"} + def parse_phg_power(?5), do: {36, "36 watts"} + def parse_phg_power(?6), do: {49, "49 watts"} + def parse_phg_power(?7), do: {64, "64 watts"} + def parse_phg_power(?8), do: {81, "81 watts"} + def parse_phg_power(?9), do: {81, "81 watts"} + def parse_phg_power(p), do: {nil, "Unknown power: #{<

>}"} + + @spec parse_phg_height(integer()) :: phg_height + def parse_phg_height(?0), do: {10, "10 feet"} + def parse_phg_height(?1), do: {20, "20 feet"} + def parse_phg_height(?2), do: {40, "40 feet"} + def parse_phg_height(?3), do: {80, "80 feet"} + def parse_phg_height(?4), do: {160, "160 feet"} + def parse_phg_height(?5), do: {320, "320 feet"} + def parse_phg_height(?6), do: {640, "640 feet"} + def parse_phg_height(?7), do: {1280, "1280 feet"} + def parse_phg_height(?8), do: {2560, "2560 feet"} + def parse_phg_height(?9), do: {5120, "5120 feet"} + def parse_phg_height(h), do: {nil, "Unknown height: #{<>}"} + + @spec parse_phg_gain(integer()) :: phg_gain + def parse_phg_gain(?0), do: {0, "0 dB"} + def parse_phg_gain(?1), do: {1, "1 dB"} + def parse_phg_gain(?2), do: {2, "2 dB"} + def parse_phg_gain(?3), do: {3, "3 dB"} + def parse_phg_gain(?4), do: {4, "4 dB"} + def parse_phg_gain(?5), do: {5, "5 dB"} + def parse_phg_gain(?6), do: {6, "6 dB"} + def parse_phg_gain(?7), do: {7, "7 dB"} + def parse_phg_gain(?8), do: {8, "8 dB"} + def parse_phg_gain(?9), do: {9, "9 dB"} + def parse_phg_gain(g), do: {nil, "Unknown gain: #{<>}"} + + @spec parse_phg_directivity(integer()) :: phg_directivity + def parse_phg_directivity(?0), do: {360, "Omni"} + def parse_phg_directivity(?1), do: {45, "45° NE"} + def parse_phg_directivity(?2), do: {90, "90° E"} + def parse_phg_directivity(?3), do: {135, "135° SE"} + def parse_phg_directivity(?4), do: {180, "180° S"} + def parse_phg_directivity(?5), do: {225, "225° SW"} + def parse_phg_directivity(?6), do: {270, "270° W"} + def parse_phg_directivity(?7), do: {315, "315° NW"} + def parse_phg_directivity(?8), do: {360, "360° N"} + def parse_phg_directivity(?9), do: {nil, "Undefined"} + def parse_phg_directivity(d), do: {nil, "Unknown directivity: #{<>}"} + + @spec parse_df_strength(integer()) :: df_strength + def parse_df_strength(?0), do: {0, "0 dB"} + def parse_df_strength(?1), do: {1, "3 dB above S0"} + def parse_df_strength(?2), do: {2, "6 dB above S0"} + def parse_df_strength(?3), do: {3, "9 dB above S0"} + def parse_df_strength(?4), do: {4, "12 dB above S0"} + def parse_df_strength(?5), do: {5, "15 dB above S0"} + def parse_df_strength(?6), do: {6, "18 dB above S0"} + def parse_df_strength(?7), do: {7, "21 dB above S0"} + def parse_df_strength(?8), do: {8, "24 dB above S0"} + def parse_df_strength(?9), do: {9, "27 dB above S0"} + def parse_df_strength(s), do: {nil, "Unknown strength: #{<>}"} +end diff --git a/lib/parser/special_data_helpers.ex b/lib/parser/special_data_helpers.ex new file mode 100644 index 0000000..9b3ab90 --- /dev/null +++ b/lib/parser/special_data_helpers.ex @@ -0,0 +1,33 @@ +defmodule Parser.SpecialDataHelpers do + @moduledoc """ + Helpers for special APRS data types: PEET logging and invalid/test data. + """ + + def parse_peet_logging(<<"*", peet_data::binary>>) do + %{ + peet_data: peet_data, + data_type: :peet_logging + } + end + + def parse_peet_logging(data) do + %{ + peet_data: data, + data_type: :peet_logging + } + end + + def parse_invalid_test_data(<<",", test_data::binary>>) do + %{ + test_data: test_data, + data_type: :invalid_test_data + } + end + + def parse_invalid_test_data(data) do + %{ + test_data: data, + data_type: :invalid_test_data + } + end +end diff --git a/lib/parser/telemetry.ex b/lib/parser/telemetry.ex index a86128c..23b2f85 100644 --- a/lib/parser/telemetry.ex +++ b/lib/parser/telemetry.ex @@ -14,9 +14,9 @@ defmodule Parser.Telemetry do digital_values = values |> Enum.drop(5) |> Enum.take(8) %{ - sequence_number: Parser.Helpers.parse_telemetry_sequence(seq), - analog_values: Parser.Helpers.parse_analog_values(analog_values), - digital_values: Parser.Helpers.parse_digital_values(digital_values), + sequence_number: Parser.TelemetryHelpers.parse_telemetry_sequence(seq), + analog_values: Parser.TelemetryHelpers.parse_analog_values(analog_values), + digital_values: Parser.TelemetryHelpers.parse_digital_values(digital_values), data_type: :telemetry, raw_data: rest } @@ -44,9 +44,9 @@ defmodule Parser.Telemetry do |> Enum.chunk_every(3) |> Enum.map(fn [a, b, c] -> %{ - a: Parser.Helpers.parse_coefficient(a), - b: Parser.Helpers.parse_coefficient(b), - c: Parser.Helpers.parse_coefficient(c) + a: Parser.TelemetryHelpers.parse_coefficient(a), + b: Parser.TelemetryHelpers.parse_coefficient(b), + c: Parser.TelemetryHelpers.parse_coefficient(c) } end) diff --git a/lib/parser/telemetry_helpers.ex b/lib/parser/telemetry_helpers.ex new file mode 100644 index 0000000..b10e906 --- /dev/null +++ b/lib/parser/telemetry_helpers.ex @@ -0,0 +1,59 @@ +defmodule Parser.TelemetryHelpers do + @moduledoc """ + Telemetry helpers for APRS. + """ + + @spec parse_telemetry_sequence(String.t()) :: integer() | nil + def parse_telemetry_sequence(seq) do + case Integer.parse(seq) do + {num, _} -> num + :error -> nil + end + end + + def parse_analog_values(values) do + Enum.map(values, &parse_analog_value/1) + end + + defp parse_analog_value("") do + nil + end + + defp parse_analog_value(value) do + case Float.parse(value) do + {float_val, _} -> float_val + :error -> nil + end + end + + def parse_digital_values(values) do + Enum.flat_map(values, &parse_digital_value/1) + end + + defp parse_digital_value("1"), do: [true] + + defp parse_digital_value(value) when is_binary(value) do + value + |> String.graphemes() + |> Enum.map(fn + "1" -> true + "0" -> false + _ -> nil + end) + end + + defp parse_digital_value(_), do: [nil] + + def parse_coefficient(coeff) do + case Float.parse(coeff) do + {float_val, _} -> + float_val + + :error -> + case Integer.parse(coeff) do + {int_val, _} -> int_val + :error -> coeff + end + end + end +end diff --git a/lib/parser/utility_helpers.ex b/lib/parser/utility_helpers.ex new file mode 100644 index 0000000..7591f1b --- /dev/null +++ b/lib/parser/utility_helpers.ex @@ -0,0 +1,72 @@ +defmodule Parser.UtilityHelpers do + @moduledoc """ + Utility and ambiguity helpers for APRS. + """ + + def count_spaces(str) do + str |> String.graphemes() |> Enum.count(fn c -> c == " " end) + end + + def count_leading_braces(packet), do: count_leading_braces(packet, 0) + + def count_leading_braces(<<"}", rest::binary>>, count), do: count_leading_braces(rest, count + 1) + + def count_leading_braces(_packet, count), do: count + + def calculate_position_ambiguity(latitude, longitude) do + lat_spaces = count_spaces(latitude) + lon_spaces = count_spaces(longitude) + + Map.get( + %{{0, 0} => 0, {1, 1} => 1, {2, 2} => 2, {3, 3} => 3, {4, 4} => 4}, + {lat_spaces, lon_spaces}, + 0 + ) + end + + def find_matches(regex, text) do + case Regex.names(regex) do + [] -> + matches = Regex.run(regex, text) + + Enum.reduce(Enum.with_index(matches), %{}, fn {match, index}, acc -> + Map.put(acc, index, match) + end) + + _ -> + Regex.named_captures(regex, text) + end + end + + def validate_position_data(latitude, longitude) do + import Decimal, only: [new: 1, add: 2, negate: 1] + + lat = + case Regex.run(~r/^(\d{2})(\d{2}\.\d+)([NS])$/, latitude) do + [_, degrees, minutes, direction] -> + lat_val = add(new(degrees), Decimal.div(new(minutes), new("60"))) + if direction == "S", do: negate(lat_val), else: lat_val + + _ -> + nil + end + + lon = + case Regex.run(~r/^(\d{3})(\d{2}\.\d+)([EW])$/, longitude) do + [_, degrees, minutes, direction] -> + lon_val = add(new(degrees), Decimal.div(new(minutes), new("60"))) + if direction == "W", do: negate(lon_val), else: lon_val + + _ -> + nil + end + + if is_struct(lat, Decimal) and is_struct(lon, Decimal) do + {:ok, {lat, lon}} + else + {:error, :invalid_position} + end + end + + def validate_timestamp(_time), do: nil +end diff --git a/lib/parser/weather.ex b/lib/parser/weather.ex index 68a6d1f..84e1d24 100644 --- a/lib/parser/weather.ex +++ b/lib/parser/weather.ex @@ -22,21 +22,21 @@ defmodule Parser.Weather do """ @spec parse_weather_data(String.t()) :: map() def parse_weather_data(weather_data) do - timestamp = Parser.Helpers.extract_timestamp(weather_data) - weather_data = Parser.Helpers.remove_timestamp(weather_data) + timestamp = Parser.WeatherHelpers.extract_timestamp(weather_data) + weather_data = Parser.WeatherHelpers.remove_timestamp(weather_data) weather_values = %{ - wind_direction: Parser.Helpers.parse_wind_direction(weather_data), - wind_speed: Parser.Helpers.parse_wind_speed(weather_data), - wind_gust: Parser.Helpers.parse_wind_gust(weather_data), - temperature: Parser.Helpers.parse_temperature(weather_data), - rain_1h: Parser.Helpers.parse_rainfall_1h(weather_data), - rain_24h: Parser.Helpers.parse_rainfall_24h(weather_data), - rain_since_midnight: Parser.Helpers.parse_rainfall_since_midnight(weather_data), - humidity: Parser.Helpers.parse_humidity(weather_data), - pressure: Parser.Helpers.parse_pressure(weather_data), - luminosity: Parser.Helpers.parse_luminosity(weather_data), - snow: Parser.Helpers.parse_snow(weather_data) + wind_direction: Parser.WeatherHelpers.parse_wind_direction(weather_data), + wind_speed: Parser.WeatherHelpers.parse_wind_speed(weather_data), + wind_gust: Parser.WeatherHelpers.parse_wind_gust(weather_data), + temperature: Parser.WeatherHelpers.parse_temperature(weather_data), + rain_1h: Parser.WeatherHelpers.parse_rainfall_1h(weather_data), + rain_24h: Parser.WeatherHelpers.parse_rainfall_24h(weather_data), + rain_since_midnight: Parser.WeatherHelpers.parse_rainfall_since_midnight(weather_data), + humidity: Parser.WeatherHelpers.parse_humidity(weather_data), + pressure: Parser.WeatherHelpers.parse_pressure(weather_data), + luminosity: Parser.WeatherHelpers.parse_luminosity(weather_data), + snow: Parser.WeatherHelpers.parse_snow(weather_data) } result = %{timestamp: timestamp, data_type: :weather, raw_weather_data: weather_data} diff --git a/lib/parser/weather_helpers.ex b/lib/parser/weather_helpers.ex new file mode 100644 index 0000000..342505a --- /dev/null +++ b/lib/parser/weather_helpers.ex @@ -0,0 +1,116 @@ +defmodule Parser.WeatherHelpers do + @moduledoc """ + Weather field extraction helpers for APRS. + """ + + @spec extract_timestamp(String.t()) :: String.t() | nil + def extract_timestamp(weather_data) do + case Regex.run(~r/^(\d{6}[hz\/])/, weather_data) do + [_, timestamp] -> timestamp + nil -> nil + end + end + + @spec remove_timestamp(String.t()) :: String.t() + def remove_timestamp(weather_data) do + case Regex.run(~r/^\d{6}[hz\/]/, weather_data) do + [timestamp] -> String.replace(weather_data, timestamp, "") + nil -> weather_data + end + end + + @spec parse_wind_direction(String.t()) :: integer() | nil + def parse_wind_direction(weather_data) do + case Regex.run(~r/(\d{3})\//, weather_data) do + [_, direction] -> String.to_integer(direction) + nil -> nil + end + end + + @spec parse_wind_speed(String.t()) :: integer() | nil + def parse_wind_speed(weather_data) do + case Regex.run(~r/\/(\d{3})/, weather_data) do + [_, speed] -> String.to_integer(speed) + nil -> nil + end + end + + @spec parse_wind_gust(String.t()) :: integer() | nil + def parse_wind_gust(weather_data) do + case Regex.run(~r/g(\d{3})/, weather_data) do + [_, gust] -> String.to_integer(gust) + nil -> nil + end + end + + @spec parse_temperature(String.t()) :: integer() | nil + def parse_temperature(weather_data) do + case Regex.run(~r/t(-?\d{3})/, weather_data) do + [_, temp] -> String.to_integer(temp) + nil -> nil + end + end + + @spec parse_rainfall_1h(String.t()) :: integer() | nil + def parse_rainfall_1h(weather_data) do + case Regex.run(~r/r(\d{3})/, weather_data) do + [_, rain] -> String.to_integer(rain) + nil -> nil + end + end + + @spec parse_rainfall_24h(String.t()) :: integer() | nil + def parse_rainfall_24h(weather_data) do + case Regex.run(~r/p(\d{3})/, weather_data) do + [_, rain] -> String.to_integer(rain) + nil -> nil + end + end + + @spec parse_rainfall_since_midnight(String.t()) :: integer() | nil + def parse_rainfall_since_midnight(weather_data) do + case Regex.run(~r/P(\d{3})/, weather_data) do + [_, rain] -> String.to_integer(rain) + nil -> nil + end + end + + @spec parse_humidity(String.t()) :: integer() | nil + def parse_humidity(weather_data) do + case Regex.run(~r/h(\d{2})/, weather_data) do + [_, humidity] -> + val = String.to_integer(humidity) + normalize_humidity(val) + + nil -> + nil + end + end + + defp normalize_humidity(0), do: 100 + defp normalize_humidity(val), do: val + + @spec parse_pressure(String.t()) :: float() | nil + def parse_pressure(weather_data) do + case Regex.run(~r/b(\d{5})/, weather_data) do + [_, pressure] -> String.to_integer(pressure) / 10.0 + nil -> nil + end + end + + @spec parse_luminosity(String.t()) :: integer() | nil + def parse_luminosity(weather_data) do + case Regex.run(~r/[lL](\d{3})/, weather_data) do + [_, luminosity] -> String.to_integer(luminosity) + nil -> nil + end + end + + @spec parse_snow(String.t()) :: integer() | nil + def parse_snow(weather_data) do + case Regex.run(~r/s(\d{3})/, weather_data) do + [_, snow] -> String.to_integer(snow) + nil -> nil + end + end +end diff --git a/test/parser/helpers_test.exs b/test/parser/helpers_test.exs index 1bbe7bf..18b8fab 100644 --- a/test/parser/helpers_test.exs +++ b/test/parser/helpers_test.exs @@ -1,65 +1,66 @@ defmodule Parser.HelpersTest do use ExUnit.Case, async: true - alias Parser.Helpers - describe "NMEA helpers" do test "parse_nmea_coordinate parses valid coordinates" do - assert Helpers.parse_nmea_coordinate("4916.45", "N") == {:ok, 49.1645} - {:ok, val} = Helpers.parse_nmea_coordinate("12311.12", "W") + assert Parser.NMEAHelpers.parse_nmea_coordinate("4916.45", "N") == {:ok, 49.1645} + {:ok, val} = Parser.NMEAHelpers.parse_nmea_coordinate("12311.12", "W") assert_in_delta val, -123.1112, 1.0e-6 end test "parse_nmea_coordinate errors on invalid input" do - assert Helpers.parse_nmea_coordinate("bad", "N") == {:error, "Invalid coordinate value"} + assert Parser.NMEAHelpers.parse_nmea_coordinate("bad", "N") == + {:error, "Invalid coordinate value"} - assert Helpers.parse_nmea_coordinate("4916.45", "Q") == + assert Parser.NMEAHelpers.parse_nmea_coordinate("4916.45", "Q") == {:error, "Invalid coordinate direction"} - assert Helpers.parse_nmea_coordinate(nil, nil) == {:error, "Invalid coordinate format"} + assert Parser.NMEAHelpers.parse_nmea_coordinate(nil, nil) == + {:error, "Invalid coordinate format"} end test "parse_nmea_sentence returns not implemented" do - assert Helpers.parse_nmea_sentence("anything") == {:error, "NMEA parsing not implemented"} + assert Parser.NMEAHelpers.parse_nmea_sentence("anything") == + {:error, "NMEA parsing not implemented"} end end describe "PHG/DF helpers" do test "parse_phg_power returns correct values" do - assert Helpers.parse_phg_power(?0) == {1, "1 watt"} - assert Helpers.parse_phg_power(?9) == {81, "81 watts"} - assert Helpers.parse_phg_power(?X) == {nil, "Unknown power: X"} + assert Parser.PHGHelpers.parse_phg_power(?0) == {1, "1 watt"} + assert Parser.PHGHelpers.parse_phg_power(?9) == {81, "81 watts"} + assert Parser.PHGHelpers.parse_phg_power(?X) == {nil, "Unknown power: X"} end test "parse_phg_height returns correct values" do - assert Helpers.parse_phg_height(?0) == {10, "10 feet"} - assert Helpers.parse_phg_height(?9) == {5120, "5120 feet"} - assert Helpers.parse_phg_height(?X) == {nil, "Unknown height: X"} + assert Parser.PHGHelpers.parse_phg_height(?0) == {10, "10 feet"} + assert Parser.PHGHelpers.parse_phg_height(?9) == {5120, "5120 feet"} + assert Parser.PHGHelpers.parse_phg_height(?X) == {nil, "Unknown height: X"} end test "parse_phg_gain returns correct values" do - assert Helpers.parse_phg_gain(?0) == {0, "0 dB"} - assert Helpers.parse_phg_gain(?9) == {9, "9 dB"} - assert Helpers.parse_phg_gain(?X) == {nil, "Unknown gain: X"} + assert Parser.PHGHelpers.parse_phg_gain(?0) == {0, "0 dB"} + assert Parser.PHGHelpers.parse_phg_gain(?9) == {9, "9 dB"} + assert Parser.PHGHelpers.parse_phg_gain(?X) == {nil, "Unknown gain: X"} end test "parse_phg_directivity returns correct values" do - assert Helpers.parse_phg_directivity(?0) == {360, "Omni"} - assert Helpers.parse_phg_directivity(?9) == {nil, "Undefined"} - assert Helpers.parse_phg_directivity(?X) == {nil, "Unknown directivity: X"} + assert Parser.PHGHelpers.parse_phg_directivity(?0) == {360, "Omni"} + assert Parser.PHGHelpers.parse_phg_directivity(?9) == {nil, "Undefined"} + assert Parser.PHGHelpers.parse_phg_directivity(?X) == {nil, "Unknown directivity: X"} end test "parse_df_strength returns correct values" do - assert Helpers.parse_df_strength(?0) == {0, "0 dB"} - assert Helpers.parse_df_strength(?9) == {9, "27 dB above S0"} - assert Helpers.parse_df_strength(?X) == {nil, "Unknown strength: X"} + assert Parser.PHGHelpers.parse_df_strength(?0) == {0, "0 dB"} + assert Parser.PHGHelpers.parse_df_strength(?9) == {9, "27 dB above S0"} + assert Parser.PHGHelpers.parse_df_strength(?X) == {nil, "Unknown strength: X"} end end describe "compressed position helpers" do test "convert_compressed_lat and lon handle valid input" do - {:ok, lat} = Helpers.convert_compressed_lat("9RZV") - {:ok, lon} = Helpers.convert_compressed_lon("9RZV") + {:ok, lat} = Parser.CompressedPositionHelpers.convert_compressed_lat("9RZV") + {:ok, lon} = Parser.CompressedPositionHelpers.convert_compressed_lon("9RZV") assert is_float(lat) assert is_float(lon) assert lat >= -90 and lat <= 90 @@ -67,24 +68,31 @@ defmodule Parser.HelpersTest do end test "convert_compressed_lat and lon return error for invalid input" do - assert Helpers.convert_compressed_lat("abcde") == {:error, "Invalid compressed latitude"} - assert Helpers.convert_compressed_lon("abcde") == {:error, "Invalid compressed longitude"} - assert Helpers.convert_compressed_lat(123) == {:error, "Invalid compressed latitude"} - assert Helpers.convert_compressed_lon(123) == {:error, "Invalid compressed longitude"} + assert Parser.CompressedPositionHelpers.convert_compressed_lat("abcde") == + {:error, "Invalid compressed latitude"} + + assert Parser.CompressedPositionHelpers.convert_compressed_lon("abcde") == + {:error, "Invalid compressed longitude"} + + assert Parser.CompressedPositionHelpers.convert_compressed_lat(123) == + {:error, "Invalid compressed latitude"} + + assert Parser.CompressedPositionHelpers.convert_compressed_lon(123) == + {:error, "Invalid compressed longitude"} end end describe "KISS/TNC2 utilities" do test "kiss_to_tnc2 decodes KISS frames" do frame = <<0xC0, 0x00, 65, 66, 67, 0xC0>> - assert Helpers.kiss_to_tnc2(frame) == "ABC" + assert Parser.KISSHelpers.kiss_to_tnc2(frame) == "ABC" frame = <<0xC0, 0x00, 0x41, 0xDB, 0xDC, 0x42, 0xDB, 0xDD, 0x43, 0xC0>> - assert Helpers.kiss_to_tnc2(frame) == "A\xC0B\xDB" <> "C" + assert Parser.KISSHelpers.kiss_to_tnc2(frame) == "A\xC0B\xDB" <> "C" end test "kiss_to_tnc2 returns error for invalid frame" do - assert Helpers.kiss_to_tnc2("notkiss") == %{ + assert Parser.KISSHelpers.kiss_to_tnc2("notkiss") == %{ error_code: :packet_invalid, error_message: "Unknown error" } @@ -92,7 +100,7 @@ defmodule Parser.HelpersTest do test "tnc2_to_kiss encodes TNC2 to KISS" do tnc2 = "A\xDBB\xC0C" - kiss = Helpers.tnc2_to_kiss(tnc2) + kiss = Parser.KISSHelpers.tnc2_to_kiss(tnc2) assert :binary.part(kiss, 0, 1) == <<0xC0>> assert :binary.part(kiss, byte_size(kiss) - 1, 1) == <<0xC0>> end @@ -100,117 +108,129 @@ defmodule Parser.HelpersTest do describe "telemetry helpers" do test "parse_telemetry_sequence parses valid values" do - assert Helpers.parse_telemetry_sequence("123") == 123 - assert Helpers.parse_telemetry_sequence("abc") == nil + assert Parser.TelemetryHelpers.parse_telemetry_sequence("123") == 123 + assert Parser.TelemetryHelpers.parse_telemetry_sequence("abc") == nil end test "parse_analog_values parses valid values" do - assert Helpers.parse_analog_values(["1.23", "4.56", ""]) == [1.23, 4.56, nil] - assert Helpers.parse_analog_values(["abc", "1.23"]) == [nil, 1.23] + assert Parser.TelemetryHelpers.parse_analog_values(["1.23", "4.56", ""]) == [ + 1.23, + 4.56, + nil + ] + + assert Parser.TelemetryHelpers.parse_analog_values(["abc", "1.23"]) == [nil, 1.23] end test "parse_digital_values parses valid values" do - assert Helpers.parse_digital_values(["101"]) == [true, false, true] - assert Helpers.parse_digital_values(["1", "0"]) == [true, false] - assert Helpers.parse_digital_values(["abc"]) == [nil, nil, nil] - assert Helpers.parse_digital_values([123]) == [nil] + assert Parser.TelemetryHelpers.parse_digital_values(["101"]) == [true, false, true] + assert Parser.TelemetryHelpers.parse_digital_values(["1", "0"]) == [true, false] + assert Parser.TelemetryHelpers.parse_digital_values(["abc"]) == [nil, nil, nil] + assert Parser.TelemetryHelpers.parse_digital_values([123]) == [nil] end test "parse_coefficient parses valid values" do - assert Helpers.parse_coefficient("1.23") == 1.23 - assert Helpers.parse_coefficient("123") == 123 - assert Helpers.parse_coefficient("abc") == "abc" + assert Parser.TelemetryHelpers.parse_coefficient("1.23") == 1.23 + assert Parser.TelemetryHelpers.parse_coefficient("123") == 123 + assert Parser.TelemetryHelpers.parse_coefficient("abc") == "abc" end end describe "weather helpers" do test "extract_timestamp handles valid formats" do - assert Helpers.extract_timestamp("092345z123") == "092345z" - assert Helpers.extract_timestamp("092345h123") == "092345h" - assert Helpers.extract_timestamp("092345/123") == "092345/" - assert Helpers.extract_timestamp("12345") == nil + assert Parser.WeatherHelpers.extract_timestamp("092345z123") == "092345z" + assert Parser.WeatherHelpers.extract_timestamp("092345h123") == "092345h" + assert Parser.WeatherHelpers.extract_timestamp("092345/123") == "092345/" + assert Parser.WeatherHelpers.extract_timestamp("12345") == nil end test "remove_timestamp removes timestamp when present" do - assert Helpers.remove_timestamp("092345z123") == "123" - assert Helpers.remove_timestamp("092345h456") == "456" - assert Helpers.remove_timestamp("092345/789") == "789" - assert Helpers.remove_timestamp("12345") == "12345" + assert Parser.WeatherHelpers.remove_timestamp("092345z123") == "123" + assert Parser.WeatherHelpers.remove_timestamp("092345h456") == "456" + assert Parser.WeatherHelpers.remove_timestamp("092345/789") == "789" + assert Parser.WeatherHelpers.remove_timestamp("12345") == "12345" end test "parse_wind_direction extracts direction" do - assert Helpers.parse_wind_direction("123/045") == 123 - assert Helpers.parse_wind_direction("invalid") == nil + assert Parser.WeatherHelpers.parse_wind_direction("123/045") == 123 + assert Parser.WeatherHelpers.parse_wind_direction("invalid") == nil end test "parse_wind_speed extracts speed" do - assert Helpers.parse_wind_speed("123/045") == 45 - assert Helpers.parse_wind_speed("invalid") == nil + assert Parser.WeatherHelpers.parse_wind_speed("123/045") == 45 + assert Parser.WeatherHelpers.parse_wind_speed("invalid") == nil end test "parse_wind_gust extracts gust speed" do - assert Helpers.parse_wind_gust("g045") == 45 - assert Helpers.parse_wind_gust("invalid") == nil + assert Parser.WeatherHelpers.parse_wind_gust("g045") == 45 + assert Parser.WeatherHelpers.parse_wind_gust("invalid") == nil end test "parse_temperature extracts temperature" do - assert Helpers.parse_temperature("t072") == 72 - assert Helpers.parse_temperature("invalid") == nil + assert Parser.WeatherHelpers.parse_temperature("t072") == 72 + assert Parser.WeatherHelpers.parse_temperature("invalid") == nil end test "parse_rainfall_1h extracts rainfall" do - assert Helpers.parse_rainfall_1h("r010") == 10 - assert Helpers.parse_rainfall_1h("invalid") == nil + assert Parser.WeatherHelpers.parse_rainfall_1h("r010") == 10 + assert Parser.WeatherHelpers.parse_rainfall_1h("invalid") == nil end test "parse_rainfall_24h extracts rainfall" do - assert Helpers.parse_rainfall_24h("p024") == 24 - assert Helpers.parse_rainfall_24h("invalid") == nil + assert Parser.WeatherHelpers.parse_rainfall_24h("p024") == 24 + assert Parser.WeatherHelpers.parse_rainfall_24h("invalid") == nil end test "parse_rainfall_since_midnight extracts rainfall" do - assert Helpers.parse_rainfall_since_midnight("P036") == 36 - assert Helpers.parse_rainfall_since_midnight("invalid") == nil + assert Parser.WeatherHelpers.parse_rainfall_since_midnight("P036") == 36 + assert Parser.WeatherHelpers.parse_rainfall_since_midnight("invalid") == nil end test "parse_humidity extracts humidity" do - assert Helpers.parse_humidity("h00") == 100 - assert Helpers.parse_humidity("h75") == 75 - assert Helpers.parse_humidity("invalid") == nil + assert Parser.WeatherHelpers.parse_humidity("h00") == 100 + assert Parser.WeatherHelpers.parse_humidity("h75") == 75 + assert Parser.WeatherHelpers.parse_humidity("invalid") == nil end test "parse_pressure extracts pressure" do - assert Helpers.parse_pressure("b10150") == 1015.0 - assert Helpers.parse_pressure("invalid") == nil + assert Parser.WeatherHelpers.parse_pressure("b10150") == 1015.0 + assert Parser.WeatherHelpers.parse_pressure("invalid") == nil end test "parse_luminosity extracts luminosity" do - assert Helpers.parse_luminosity("l123") == 123 - assert Helpers.parse_luminosity("L456") == 456 - assert Helpers.parse_luminosity("invalid") == nil + assert Parser.WeatherHelpers.parse_luminosity("l123") == 123 + assert Parser.WeatherHelpers.parse_luminosity("L456") == 456 + assert Parser.WeatherHelpers.parse_luminosity("invalid") == nil end test "parse_snow extracts snow depth" do - assert Helpers.parse_snow("s012") == 12 - assert Helpers.parse_snow("invalid") == nil + assert Parser.WeatherHelpers.parse_snow("s012") == 12 + assert Parser.WeatherHelpers.parse_snow("invalid") == nil end end describe "PEET logging helpers" do test "parse_peet_logging handles data with and without prefix" do - assert Helpers.parse_peet_logging("*1234") == %{peet_data: "1234", data_type: :peet_logging} - assert Helpers.parse_peet_logging("1234") == %{peet_data: "1234", data_type: :peet_logging} + assert Parser.SpecialDataHelpers.parse_peet_logging("*1234") == %{ + peet_data: "1234", + data_type: :peet_logging + } + + assert Parser.SpecialDataHelpers.parse_peet_logging("1234") == %{ + peet_data: "1234", + data_type: :peet_logging + } end end describe "invalid/test data helpers" do test "parse_invalid_test_data handles data with and without comma" do - assert Helpers.parse_invalid_test_data(",1234") == %{ + assert Parser.SpecialDataHelpers.parse_invalid_test_data(",1234") == %{ test_data: "1234", data_type: :invalid_test_data } - assert Helpers.parse_invalid_test_data("1234") == %{ + assert Parser.SpecialDataHelpers.parse_invalid_test_data("1234") == %{ test_data: "1234", data_type: :invalid_test_data } @@ -219,53 +239,63 @@ defmodule Parser.HelpersTest do describe "utility helpers" do test "count_spaces counts spaces correctly" do - assert Helpers.count_spaces("a b c") == 2 - assert Helpers.count_spaces("abc") == 0 - assert Helpers.count_spaces(" ") == 1 + assert Parser.UtilityHelpers.count_spaces("a b c") == 2 + assert Parser.UtilityHelpers.count_spaces("abc") == 0 + assert Parser.UtilityHelpers.count_spaces(" ") == 1 end test "count_leading_braces counts braces correctly" do - assert Helpers.count_leading_braces("}}abc") == 2 - assert Helpers.count_leading_braces("abc") == 0 - assert Helpers.count_leading_braces("}a}bc") == 1 + assert Parser.UtilityHelpers.count_leading_braces("}}abc") == 2 + assert Parser.UtilityHelpers.count_leading_braces("abc") == 0 + assert Parser.UtilityHelpers.count_leading_braces("}a}bc") == 1 end test "calculate_position_ambiguity determines ambiguity level" do - assert Helpers.calculate_position_ambiguity("12 34.56N", "123 45.67W") == 1 - assert Helpers.calculate_position_ambiguity("12 34.56N", "123 45.67W") == 2 - assert Helpers.calculate_position_ambiguity("12 34.56N", "123 45.67W") == 3 - assert Helpers.calculate_position_ambiguity("12 34.56N", "123 45.67W") == 4 - assert Helpers.calculate_position_ambiguity("1234.56N", "12345.67W") == 0 + assert Parser.UtilityHelpers.calculate_position_ambiguity("12 34.56N", "123 45.67W") == 1 + assert Parser.UtilityHelpers.calculate_position_ambiguity("12 34.56N", "123 45.67W") == 2 + + assert Parser.UtilityHelpers.calculate_position_ambiguity("12 34.56N", "123 45.67W") == + 3 + + assert Parser.UtilityHelpers.calculate_position_ambiguity("12 34.56N", "123 45.67W") == + 4 + + assert Parser.UtilityHelpers.calculate_position_ambiguity("1234.56N", "12345.67W") == 0 end test "calculate_compressed_ambiguity determines compressed ambiguity" do - assert Helpers.calculate_compressed_ambiguity(" ") == 0 - assert Helpers.calculate_compressed_ambiguity("!") == 1 - assert Helpers.calculate_compressed_ambiguity("\"") == 2 - assert Helpers.calculate_compressed_ambiguity("#") == 3 - assert Helpers.calculate_compressed_ambiguity("$") == 4 - assert Helpers.calculate_compressed_ambiguity("X") == 0 + assert Parser.CompressedPositionHelpers.calculate_compressed_ambiguity(" ") == 0 + assert Parser.CompressedPositionHelpers.calculate_compressed_ambiguity("!") == 1 + assert Parser.CompressedPositionHelpers.calculate_compressed_ambiguity("\"") == 2 + assert Parser.CompressedPositionHelpers.calculate_compressed_ambiguity("#") == 3 + assert Parser.CompressedPositionHelpers.calculate_compressed_ambiguity("$") == 4 + assert Parser.CompressedPositionHelpers.calculate_compressed_ambiguity("X") == 0 end test "validate_position_data validates positions" do - assert {:ok, {lat, lon}} = Helpers.validate_position_data("4916.45N", "12311.12W") + assert {:ok, {lat, lon}} = + Parser.UtilityHelpers.validate_position_data("4916.45N", "12311.12W") + assert_in_delta Decimal.to_float(lat), 49.2742, 0.0001 assert_in_delta Decimal.to_float(lon), -123.1853, 0.0001 - assert Helpers.validate_position_data("invalid", "12311.12W") == {:error, :invalid_position} - assert Helpers.validate_position_data("4916.45N", "invalid") == {:error, :invalid_position} + assert Parser.UtilityHelpers.validate_position_data("invalid", "12311.12W") == + {:error, :invalid_position} + + assert Parser.UtilityHelpers.validate_position_data("4916.45N", "invalid") == + {:error, :invalid_position} end test "validate_timestamp always returns nil" do - assert Helpers.validate_timestamp("any") == nil + assert Parser.UtilityHelpers.validate_timestamp("any") == nil end test "find_matches handles named and unnamed captures" do regex = ~r/(?\d+)/ - assert Helpers.find_matches(regex, "abc123def") == %{"val" => "123"} + assert Parser.UtilityHelpers.find_matches(regex, "abc123def") == %{"val" => "123"} regex = ~r/(\d+)/ - assert Helpers.find_matches(regex, "abc123def") == %{0 => "123", 1 => "123"} + assert Parser.UtilityHelpers.find_matches(regex, "abc123def") == %{0 => "123", 1 => "123"} end end end