diff --git a/lib/aprs/bad_packet.ex b/lib/aprs/bad_packet.ex index de2bac6..f75cbf0 100644 --- a/lib/aprs/bad_packet.ex +++ b/lib/aprs/bad_packet.ex @@ -15,6 +15,7 @@ defmodule Aprs.BadPacket do end @doc false + @spec changeset(%Aprs.BadPacket{}, map()) :: Ecto.Changeset.t() def changeset(bad_packet, attrs) do bad_packet |> cast(attrs, [:raw_packet, :error_message, :error_type, :attempted_at]) @@ -24,6 +25,7 @@ defmodule Aprs.BadPacket do @doc """ Returns recent bad packets, ordered by attempted_at descending """ + @spec recent(Ecto.Queryable.t(), pos_integer()) :: Ecto.Query.t() def recent(query \\ __MODULE__, limit \\ 100) do from(b in query, order_by: [desc: b.attempted_at], @@ -34,6 +36,7 @@ defmodule Aprs.BadPacket do @doc """ Returns bad packets by error type """ + @spec by_error_type(Ecto.Queryable.t(), String.t()) :: Ecto.Query.t() def by_error_type(query \\ __MODULE__, error_type) do from(b in query, where: b.error_type == ^error_type @@ -43,6 +46,7 @@ defmodule Aprs.BadPacket do @doc """ Returns count of bad packets in the last N hours """ + @spec count_recent(pos_integer()) :: Ecto.Query.t() def count_recent(hours \\ 24) do cutoff = DateTime.add(DateTime.utc_now(), -hours * 3600, :second) diff --git a/lib/aprs/convert.ex b/lib/aprs/convert.ex index e90dc37..61fcb87 100644 --- a/lib/aprs/convert.ex +++ b/lib/aprs/convert.ex @@ -1,8 +1,12 @@ defmodule Aprs.Convert do @moduledoc false + + @spec wind(number(), :ultimeter, :mph) :: float() def wind(speed, :ultimeter, :mph), do: speed * 0.0621371192 + @spec temp(number(), :ultimeter, :f) :: float() def temp(value, :ultimeter, :f), do: value * 0.1 + @spec speed(number(), :knots, :mph) :: float() def speed(value, :knots, :mph), do: Float.round(value * 1.15077945, 2) end diff --git a/lib/aprs/data_extended.ex b/lib/aprs/data_extended.ex index 7f32bbc..e662d64 100644 --- a/lib/aprs/data_extended.ex +++ b/lib/aprs/data_extended.ex @@ -17,6 +17,7 @@ defmodule Aprs.DataExtended do end @doc false + @spec changeset(%Aprs.DataExtended{}, map()) :: Ecto.Changeset.t() def changeset(%DataExtended{} = data_extended, attrs) do data_extended |> cast(attrs, [ diff --git a/lib/aprs/encoding_utils.ex b/lib/aprs/encoding_utils.ex index 8bf8ae5..852e6c1 100644 --- a/lib/aprs/encoding_utils.ex +++ b/lib/aprs/encoding_utils.ex @@ -7,6 +7,8 @@ defmodule Aprs.EncodingUtils do to web clients. """ + alias Parser.Types.MicE + @doc """ Sanitizes a binary to ensure it can be safely JSON encoded. @@ -22,6 +24,7 @@ defmodule Aprs.EncodingUtils do iex> Aprs.EncodingUtils.sanitize_string(<<72, 101, 108, 108, 111, 211, 87, 111, 114, 108, 100>>) "HelloWorld" """ + @spec sanitize_string(binary() | nil | any()) :: binary() | nil | any() def sanitize_string(binary) when is_binary(binary) do # Always scrub problematic bytes, even from valid UTF-8 strings # PostgreSQL rejects null bytes and other control characters even if they're valid UTF-8 @@ -34,6 +37,7 @@ defmodule Aprs.EncodingUtils do @doc """ Sanitizes all string fields in an APRS packet to ensure safe JSON encoding. """ + @spec sanitize_packet(struct() | map()) :: struct() | map() def sanitize_packet(%Aprs.Packet{} = packet) do %{ packet @@ -51,13 +55,14 @@ defmodule Aprs.EncodingUtils do @doc """ Sanitizes string fields in the data_extended structure. """ + @spec sanitize_data_extended(nil | map() | MicE.t() | any()) :: nil | map() | MicE.t() | any() def sanitize_data_extended(nil), do: nil def sanitize_data_extended(%{comment: comment} = data_extended) when is_map(data_extended) do %{data_extended | comment: sanitize_string(comment)} end - def sanitize_data_extended(%Parser.Types.MicE{message: message} = mic_e) do + def sanitize_data_extended(%MicE{message: message} = mic_e) do %{mic_e | message: sanitize_string(message)} end @@ -78,6 +83,7 @@ defmodule Aprs.EncodingUtils do # Private helper functions + @spec scrub_problematic_bytes(binary()) :: String.t() defp scrub_problematic_bytes(binary) do # Handle both invalid UTF-8 sequences and problematic control characters cleaned = @@ -101,11 +107,13 @@ defmodule Aprs.EncodingUtils do end # Check if byte should be kept (ASCII printable + safe whitespace) + @spec valid_utf8_byte?(integer()) :: boolean() defp valid_utf8_byte?(byte) when byte >= 32 and byte <= 126, do: true defp valid_utf8_byte?(byte) when byte in [9, 10, 13], do: true defp valid_utf8_byte?(_), do: false # Ensure the final result is valid UTF-8 + @spec ensure_valid_utf8(binary()) :: binary() defp ensure_valid_utf8(binary) do if String.valid?(binary) do binary @@ -133,6 +141,7 @@ defmodule Aprs.EncodingUtils do iex> Aprs.EncodingUtils.to_hex(<<72, 101, 108, 108, 111>>) "48656C6C6F" """ + @spec to_hex(binary()) :: String.t() def to_hex(binary) when is_binary(binary) do binary |> :binary.bin_to_list() @@ -151,6 +160,7 @@ defmodule Aprs.EncodingUtils do iex> Aprs.EncodingUtils.encoding_info(<<72, 101, 211, 108, 111>>) %{valid_utf8: false, byte_count: 5, invalid_at: 2} """ + @spec encoding_info(binary()) :: map() def encoding_info(binary) when is_binary(binary) do valid = String.valid?(binary) byte_count = byte_size(binary) @@ -169,6 +179,7 @@ defmodule Aprs.EncodingUtils do end end + @spec find_invalid_byte_position(binary(), non_neg_integer()) :: non_neg_integer() | nil defp find_invalid_byte_position(<<>>, _pos), do: nil defp find_invalid_byte_position(binary, pos) do diff --git a/lib/aprs/is/is.ex b/lib/aprs/is/is.ex index 6e0c762..1f7ce29 100644 --- a/lib/aprs/is/is.ex +++ b/lib/aprs/is/is.ex @@ -9,6 +9,22 @@ defmodule Aprs.Is do @aprs_timeout 60 * 1000 @keepalive_interval 20 * 1000 + @type state :: %{ + server: charlist() | String.t(), + port: pos_integer(), + socket: :ssl.sslsocket() | nil, + timer: reference() | nil, + keepalive_timer: reference() | nil, + connected_at: DateTime.t(), + packet_stats: map(), + buffer: String.t(), + login_params: %{ + user_id: String.t(), + passcode: String.t(), + filter: String.t() + } + } + def start_link(opts \\ []) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @@ -139,6 +155,7 @@ defmodule Aprs.Is do # Server methods + @spec connect_to_aprs_is(String.t() | charlist(), pos_integer()) :: {:ok, :ssl.sslsocket()} | {:error, any()} defp connect_to_aprs_is(server, port) do # Additional safeguard: prevent connections in test environment if Application.get_env(:aprs, :env) == :test or @@ -152,6 +169,7 @@ defmodule Aprs.Is do end end + @spec send_login_string(:ssl.sslsocket(), String.t(), String.t(), String.t()) :: :ok | {:error, any()} defp send_login_string(socket, aprs_user_id, aprs_passcode, filter) do login_string = "user #{aprs_user_id} pass #{aprs_passcode} vers aprs.me 0.1 filter #{filter}\r\n" @@ -161,10 +179,12 @@ defmodule Aprs.Is do :gen_tcp.send(socket, login_string) end + @spec create_timer(non_neg_integer()) :: reference() defp create_timer(timeout) do Process.send_after(self(), :aprs_no_message_timeout, timeout) end + @spec create_keepalive_timer(non_neg_integer()) :: reference() defp create_keepalive_timer(interval) do Process.send_after(self(), :send_keepalive, interval) end @@ -216,7 +236,9 @@ defmodule Aprs.Is do end end - def handle_info({:tcp, _socket, packet}, state) do + @impl true + @spec handle_info({:ssl, port(), binary()} | any(), state()) :: {:noreply, state()} + def handle_info({:ssl, _socket, data}, state) do # Cancel the previous timer Process.cancel_timer(state.timer) @@ -225,7 +247,7 @@ defmodule Aprs.Is do packet_stats = update_packet_stats(state.packet_stats, current_time) # Append new packet data to buffer - buffer = state.buffer <> packet + buffer = state.buffer <> data # Process complete lines (ending with \r\n or \n) {complete_lines, remaining_buffer} = extract_complete_lines(buffer) @@ -262,6 +284,7 @@ defmodule Aprs.Is do end # Extract complete lines from buffer, returning {complete_lines, remaining_buffer} + @spec extract_complete_lines(String.t()) :: {[String.t()], String.t()} defp extract_complete_lines(buffer) do # Split by both \r\n and \n to handle different line endings parts = String.split(buffer, ~r/\r?\n/, parts: :infinity) @@ -388,6 +411,7 @@ defmodule Aprs.Is do end # Helper to check if a packet has position data worth storing + @spec has_position_data?(map()) :: boolean() defp has_position_data?(packet) do require Logger @@ -425,6 +449,7 @@ defmodule Aprs.Is do end # Helper to validate coordinate values + @spec are_valid_coords?(any(), any()) :: boolean() defp are_valid_coords?(lat, lon) do require Logger @@ -454,6 +479,7 @@ defmodule Aprs.Is do end # Helper to convert various types to float + @spec to_float(any()) :: float() | nil defp to_float(value) when is_float(value), do: value defp to_float(value) when is_integer(value), do: value * 1.0 defp to_float(%Decimal{} = value), do: Decimal.to_float(value) @@ -468,6 +494,7 @@ defmodule Aprs.Is do defp to_float(_), do: nil # Normalize data_type to ensure proper storage + @spec normalize_data_type(map()) :: map() defp normalize_data_type(%{data_type: data_type} = attrs) when is_atom(data_type) do %{attrs | data_type: to_string(data_type)} end @@ -478,6 +505,7 @@ defmodule Aprs.Is do # :ets.select_count(:aprs_messages, total_spec) # end + @spec update_packet_stats(map(), integer()) :: map() defp update_packet_stats(stats, current_time) do new_total = stats.total_packets + 1 @@ -503,10 +531,12 @@ defmodule Aprs.Is do end end + @spec server_to_string(String.t() | charlist() | any()) :: String.t() defp server_to_string(server) when is_list(server), do: List.to_string(server) defp server_to_string(server) when is_binary(server), do: server defp server_to_string(server), do: to_string(server) + @spec default_packet_stats() :: map() defp default_packet_stats do %{ total_packets: 0, @@ -519,6 +549,7 @@ defmodule Aprs.Is do # Helper function to recursively convert structs to maps # This handles nested structs that Map.from_struct/1 cannot handle + @spec struct_to_map(any()) :: any() defp struct_to_map(%{__struct__: struct_type} = struct) do converted_map = struct diff --git a/lib/aprs/packet.ex b/lib/aprs/packet.ex index 0fffad5..f689afc 100644 --- a/lib/aprs/packet.ex +++ b/lib/aprs/packet.ex @@ -61,6 +61,7 @@ defmodule Aprs.Packet do end @doc false + @spec changeset(%Aprs.Packet{}, map()) :: Ecto.Changeset.t() def changeset(packet, attrs) do # Convert atom data_type to string attrs = normalize_data_type(attrs) @@ -121,6 +122,7 @@ defmodule Aprs.Packet do |> maybe_set_has_position() end + @spec maybe_create_geometry_from_lat_lon(Ecto.Changeset.t()) :: Ecto.Changeset.t() defp maybe_create_geometry_from_lat_lon(changeset) do lat = get_field(changeset, :lat) || get_change(changeset, :lat) lon = get_field(changeset, :lon) || get_change(changeset, :lon) @@ -207,9 +209,10 @@ defmodule Aprs.Packet do defp normalize_data_type(attrs), do: attrs @doc """ - Extracts additional data from the parsed packet's data_extended field + Extracts additional data from the raw packet and data_extended structure and merges it with the packet attributes for storage. """ + @spec extract_additional_data(map(), String.t() | nil) :: map() def extract_additional_data(attrs, raw_packet \\ nil) do data_extended = attrs[:data_extended] || attrs["data_extended"] || %{} @@ -446,6 +449,7 @@ defmodule Aprs.Packet do @doc """ Create a geometry point from lat/lon coordinates. """ + @spec create_point(number() | nil, number() | nil) :: Geo.Point.t() | nil def create_point(lat, lon) when is_number(lat) and is_number(lon) do if lat >= -90 and lat <= 90 and lon >= -180 and lon <= 180 do %Geo.Point{coordinates: {lon, lat}, srid: 4326} @@ -457,18 +461,21 @@ defmodule Aprs.Packet do @doc """ Extract lat/lon from a PostGIS geometry point. """ + @spec extract_coordinates(Geo.Point.t() | any()) :: {number() | nil, number() | nil} def extract_coordinates(%Geo.Point{coordinates: {lon, lat}}), do: {lat, lon} def extract_coordinates(_), do: {nil, nil} @doc """ Get latitude from a packet's location geometry. """ + @spec lat(%Aprs.Packet{}) :: number() | nil def lat(%__MODULE__{location: %Geo.Point{coordinates: {_lon, lat}}}), do: lat def lat(_), do: nil @doc """ Get longitude from a packet's location geometry. """ + @spec lon(%Aprs.Packet{}) :: number() | nil def lon(%__MODULE__{location: %Geo.Point{coordinates: {lon, _lat}}}), do: lon def lon(_), do: nil diff --git a/lib/aprs/packet_replay.ex b/lib/aprs/packet_replay.ex index 0d5a129..9c55c56 100644 --- a/lib/aprs/packet_replay.ex +++ b/lib/aprs/packet_replay.ex @@ -19,6 +19,24 @@ defmodule Aprs.PacketReplay do @default_replay_window_minutes 60 @default_replay_speed 5.0 + @type state :: %{ + user_id: String.t(), + replay_topic: String.t(), + replay_speed: float(), + start_time: DateTime.t(), + end_time: DateTime.t(), + region: String.t() | nil, + bounds: map(), + callsign: String.t() | nil, + with_position: boolean(), + limit: pos_integer(), + paused: boolean(), + packets_sent: non_neg_integer(), + replay_started_at: DateTime.t(), + replay_timer: reference() | nil, + last_packet_time: DateTime.t() | nil + } + # Client API @doc """ @@ -34,6 +52,7 @@ defmodule Aprs.PacketReplay do * `:limit` - Maximum number of packets to replay (default: 5000) * `:with_position` - Only include packets with position data (default: true) """ + @spec start_replay(keyword()) :: {:ok, pid()} | {:error, any()} def start_replay(opts) do user_id = Keyword.fetch!(opts, :user_id) @@ -53,6 +72,7 @@ defmodule Aprs.PacketReplay do @doc """ Stops an active replay session for a user. """ + @spec stop_replay(String.t()) :: :ok def stop_replay(user_id) do name = via_tuple(user_id) @@ -66,6 +86,7 @@ defmodule Aprs.PacketReplay do @doc """ Pauses an active replay session. """ + @spec pause_replay(String.t()) :: :ok | :already_paused def pause_replay(user_id) do name = via_tuple(user_id) GenServer.call(name, :pause) @@ -74,6 +95,7 @@ defmodule Aprs.PacketReplay do @doc """ Resumes a paused replay session. """ + @spec resume_replay(String.t()) :: :ok def resume_replay(user_id) do name = via_tuple(user_id) GenServer.call(name, :resume) @@ -82,6 +104,7 @@ defmodule Aprs.PacketReplay do @doc """ Changes the replay speed. """ + @spec set_replay_speed(String.t(), number()) :: :ok def set_replay_speed(user_id, speed) when is_number(speed) and speed > 0 do name = via_tuple(user_id) GenServer.call(name, {:set_speed, speed}) @@ -90,6 +113,7 @@ defmodule Aprs.PacketReplay do @doc """ Updates replay filters (region, bounds, callsign, etc.) """ + @spec update_filters(String.t(), keyword()) :: :ok def update_filters(user_id, filters) when is_list(filters) do name = via_tuple(user_id) GenServer.call(name, {:update_filters, filters}) @@ -98,6 +122,7 @@ defmodule Aprs.PacketReplay do @doc """ Gets information about the current replay session. """ + @spec get_replay_info(String.t()) :: map() def get_replay_info(user_id) do name = via_tuple(user_id) GenServer.call(name, :get_info) @@ -106,6 +131,7 @@ defmodule Aprs.PacketReplay do # Server implementation @impl true + @spec init(keyword()) :: {:ok, state()} def init(opts) do user_id = Keyword.fetch!(opts, :user_id) @@ -152,6 +178,7 @@ defmodule Aprs.PacketReplay do end @impl true + @spec handle_info(any(), state()) :: {:noreply, state()} | {:stop, :normal, state()} def handle_info(:start_replay, state) do # Fetch historical packets based on filters # Always filter by bounds (visible map area) and limit to packets with position @@ -245,6 +272,7 @@ defmodule Aprs.PacketReplay do end @impl true + @spec handle_call(any(), {pid(), any()}, state()) :: {:reply, any(), state()} def handle_call(:pause, _from, state) do if state.replay_timer do Process.cancel_timer(state.replay_timer) @@ -345,6 +373,7 @@ defmodule Aprs.PacketReplay do end @impl true + @spec terminate(any(), state()) :: :ok def terminate(_reason, state) do # Clean up any timers if state.replay_timer do @@ -362,10 +391,12 @@ defmodule Aprs.PacketReplay do # Helper functions + @spec via_tuple(String.t()) :: {:via, Registry, {atom(), String.t()}} defp via_tuple(user_id) do {:via, Registry, {Aprs.ReplayRegistry, "replay:#{user_id}"}} end + @spec sanitize_packet_for_transport(any()) :: map() defp sanitize_packet_for_transport(packet) do # Convert to map and ensure all fields are JSON-safe packet @@ -374,6 +405,7 @@ defmodule Aprs.PacketReplay do |> sanitize_map_values() end + @spec sanitize_map_values(map()) :: map() defp sanitize_map_values(map) when is_map(map) do Enum.reduce(map, %{}, fn {k, v}, acc -> Map.put(acc, k, sanitize_value(v)) diff --git a/lib/aprs/packets.ex b/lib/aprs/packets.ex index 48eaa9b..c4bbac7 100644 --- a/lib/aprs/packets.ex +++ b/lib/aprs/packets.ex @@ -16,6 +16,7 @@ defmodule Aprs.Packets do ## Parameters * `packet_data` - Map containing packet data to be stored """ + @spec store_packet(map()) :: {:ok, struct()} | {:error, :validation_error | :storage_exception} def store_packet(packet_data) do require Logger @@ -114,6 +115,7 @@ defmodule Aprs.Packets do * `packet_data` - The original packet data that failed to parse * `error` - The error that occurred during parsing """ + @spec store_bad_packet(map() | String.t(), any()) :: {:ok, struct()} | {:error, Ecto.Changeset.t()} def store_bad_packet(packet_data, error) do error_type = case error do @@ -216,6 +218,7 @@ defmodule Aprs.Packets do @doc """ Gets historical packet count for a map area. """ + @spec get_historical_packet_count(map()) :: non_neg_integer() def get_historical_packet_count(opts \\ %{}) do base_query = from(p in Packet, select: count(p.id), where: p.has_position == true) @@ -262,9 +265,10 @@ defmodule Aprs.Packets do defp filter_by_time(query, _), do: query @doc """ - Get packets only from the last hour for current display. + Gets recent packets for the map view. This is used for initial map loading to show only recent packets. """ + @spec get_recent_packets(map()) :: [struct()] def get_recent_packets(opts \\ %{}) do # Always limit to the last hour one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) @@ -361,6 +365,7 @@ defmodule Aprs.Packets do @doc """ Gets the total count of stored packets in the database. """ + @spec get_total_packet_count() :: non_neg_integer() def get_total_packet_count do Repo.one(from p in Packet, select: count(p.id)) end @@ -382,17 +387,20 @@ defmodule Aprs.Packets do end @doc """ - Clean packets older than a specific number of days. + Clean packets older than a specific number of days. - This function allows for more granular cleanup operations by specifying - the exact age threshold for packet deletion. + This function allows for more granular cleanup operations by specifying + the exact age threshold for packet deletion. + @doc \""" + Removes packets older than the specified number of days. ## Parameters - - `days` - Number of days to retain packets (packets older than this will be deleted) + - `days` - Number of days to keep (packets older than this will be deleted) ## Returns - Number of packets deleted """ + @spec clean_packets_older_than(pos_integer()) :: non_neg_integer() def clean_packets_older_than(days) when is_integer(days) and days > 0 do cutoff_time = DateTime.add(DateTime.utc_now(), -days * 86_400, :second) @@ -481,6 +489,7 @@ defmodule Aprs.Packets do end # Get packets from last hour only - used to initialize the map + @spec get_last_hour_packets() :: [struct()] def get_last_hour_packets do one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) diff --git a/lib/aprs/passcode.ex b/lib/aprs/passcode.ex index 07d0cb4..b72a775 100644 --- a/lib/aprs/passcode.ex +++ b/lib/aprs/passcode.ex @@ -17,6 +17,7 @@ defmodule Aprs.Passcode do iex> Aprs.Passcode.generate("W5ISP") 15748 """ + @spec generate(String.t()) :: non_neg_integer() def generate(callsign) when is_binary(callsign) do # Split on '-' and take first part, uppercase, and limit to 10 chars realcall = diff --git a/lib/aprs/workers/packet_cleanup_worker.ex b/lib/aprs/workers/packet_cleanup_worker.ex index e2c8097..5a99c17 100644 --- a/lib/aprs/workers/packet_cleanup_worker.ex +++ b/lib/aprs/workers/packet_cleanup_worker.ex @@ -25,6 +25,7 @@ defmodule Aprs.Workers.PacketCleanupWorker do require Logger @impl Oban.Worker + @spec perform(Oban.Job.t()) :: :ok | {:error, String.t()} def perform(%Oban.Job{args: %{"cleanup_days" => days}}) when is_integer(days) do Logger.info("Starting scheduled APRS packet cleanup for packets older than #{days} days") @@ -41,6 +42,7 @@ defmodule Aprs.Workers.PacketCleanupWorker do :ok end + @spec perform(Oban.Job.t()) :: :ok | {:error, String.t()} def perform(%Oban.Job{args: _args}) do Logger.info("Starting scheduled APRS packet cleanup") @@ -78,6 +80,7 @@ defmodule Aprs.Workers.PacketCleanupWorker do ## Returns - Number of packets deleted """ + @spec cleanup_packets_older_than(pos_integer()) :: non_neg_integer() def cleanup_packets_older_than(days) when is_integer(days) and days > 0 do Logger.info("Starting APRS packet cleanup for packets older than #{days} days") diff --git a/lib/aprs_web/helpers/aprs_symbols.ex b/lib/aprs_web/helpers/aprs_symbols.ex index b9b35fe..528721d 100644 --- a/lib/aprs_web/helpers/aprs_symbols.ex +++ b/lib/aprs_web/helpers/aprs_symbols.ex @@ -22,6 +22,7 @@ defmodule AprsWeb.Helpers.AprsSymbols do iex> AprsWeb.Helpers.AprsSymbols.get_sprite_filename("\\") "aprs-symbols-24-1.png" """ + @spec get_sprite_filename(String.t()) :: String.t() def get_sprite_filename(symbol_table_id) do case symbol_table_id do "/" -> "aprs-symbols-24-0.png" @@ -34,6 +35,7 @@ defmodule AprsWeb.Helpers.AprsSymbols do @doc """ Get the high-resolution sprite sheet filename for retina displays. """ + @spec get_sprite_filename_2x(String.t()) :: String.t() def get_sprite_filename_2x(symbol_table_id) do case symbol_table_id do "/" -> "aprs-symbols-24-0@2x.png" @@ -56,6 +58,7 @@ defmodule AprsWeb.Helpers.AprsSymbols do iex> AprsWeb.Helpers.AprsSymbols.get_symbol_position("!") {-216, 0} # ASCII 33, position 1 -> column 1, row 0 """ + @spec get_symbol_position(String.t() | integer()) :: {integer(), integer()} def get_symbol_position(symbol_code) when is_binary(symbol_code) do # Get first character if string char_code = symbol_code |> String.first() |> String.to_charlist() |> List.first() @@ -92,6 +95,7 @@ defmodule AprsWeb.Helpers.AprsSymbols do iex> AprsWeb.Helpers.AprsSymbols.symbol_css_style("/", ">") "background-image: url('/aprs-symbols/aprs-symbols-24-0.png'); background-position: -1440px 0px; width: 24px; height: 24px;" """ + @spec symbol_css_style(String.t(), String.t() | integer()) :: String.t() def symbol_css_style(symbol_table_id, symbol_code) do sprite_file = get_sprite_filename(symbol_table_id) {x, y} = get_symbol_position(symbol_code) @@ -105,6 +109,7 @@ defmodule AprsWeb.Helpers.AprsSymbols do @doc """ Generate HTML for an APRS symbol with proper sprite positioning. """ + @spec symbol_html(String.t(), String.t() | integer(), keyword()) :: {:safe, String.t()} def symbol_html(symbol_table_id, symbol_code, opts \\ []) do css_class = Keyword.get(opts, :class, "aprs-symbol") extra_style = Keyword.get(opts, :style, "") @@ -119,6 +124,7 @@ defmodule AprsWeb.Helpers.AprsSymbols do Get a data URL for the symbol that can be used in JavaScript/Leaflet. This creates a small canvas with just the symbol extracted from the sprite. """ + @spec get_symbol_data_attributes(String.t(), String.t() | integer()) :: map() def get_symbol_data_attributes(symbol_table_id, symbol_code) do sprite_file = get_sprite_filename(symbol_table_id) sprite_file_2x = get_sprite_filename_2x(symbol_table_id) @@ -137,6 +143,7 @@ defmodule AprsWeb.Helpers.AprsSymbols do @doc """ Get the default symbol for unknown or invalid symbols. """ + @spec default_symbol() :: {String.t(), String.t()} def default_symbol do # Car icon as default {"/", ">"} @@ -145,6 +152,7 @@ defmodule AprsWeb.Helpers.AprsSymbols do @doc """ Validate if a symbol table ID and code combination is valid. """ + @spec valid_symbol?(any(), any()) :: boolean() def valid_symbol?(symbol_table_id, symbol_code) do case {symbol_table_id, symbol_code} do {table, code} when table in ["/", "\\"] and is_binary(code) and byte_size(code) > 0 -> @@ -161,6 +169,7 @@ defmodule AprsWeb.Helpers.AprsSymbols do This is a subset of common symbols - for a complete list, you'd want to reference the official APRS symbol specification. """ + @spec symbol_description(String.t(), String.t()) :: String.t() def symbol_description(symbol_table_id, symbol_code) do case {symbol_table_id, symbol_code} do # Primary Table (/) diff --git a/lib/aprs_web/live/map_live/index.ex b/lib/aprs_web/live/map_live/index.ex index 498e148..4a284fb 100644 --- a/lib/aprs_web/live/map_live/index.ex +++ b/lib/aprs_web/live/map_live/index.ex @@ -8,6 +8,7 @@ defmodule AprsWeb.MapLive.Index do alias AprsWeb.Endpoint alias AprsWeb.Helpers.AprsSymbols alias Parser.Types.MicE + alias Phoenix.LiveView.Socket @default_center %{lat: 39.8283, lng: -98.5795} @default_zoom 5 @@ -30,6 +31,7 @@ defmodule AprsWeb.MapLive.Index do {:ok, socket} end + @spec assign_defaults(Socket.t(), DateTime.t()) :: Socket.t() defp assign_defaults(socket, one_hour_ago) do assign(socket, packets: [], @@ -61,6 +63,7 @@ defmodule AprsWeb.MapLive.Index do ) end + @spec maybe_start_geolocation(Socket.t()) :: Socket.t() defp maybe_start_geolocation(socket) do if Application.get_env(:aprs, :disable_aprs_connection, false) != true do ip = @@ -81,6 +84,8 @@ defmodule AprsWeb.MapLive.Index do end) end end + + socket end defp schedule_timers do @@ -242,6 +247,7 @@ defmodule AprsWeb.MapLive.Index do {:noreply, socket} end + @spec handle_bounds_update(map(), Socket.t()) :: {:noreply, Socket.t()} defp handle_bounds_update(bounds, socket) do # Update the map bounds from the client map_bounds = %{ @@ -270,6 +276,7 @@ defmodule AprsWeb.MapLive.Index do end end + @spec process_bounds_update(map(), Socket.t()) :: Socket.t() defp process_bounds_update(map_bounds, socket) do # Filter visible packets to only include those within the new bounds and time threshold new_visible_packets = @@ -709,6 +716,7 @@ defmodule AprsWeb.MapLive.Index do end # Check if a packet is within the time threshold (not too old) + @spec packet_within_time_threshold?(map(), DateTime.t()) :: boolean() defp packet_within_time_threshold?(packet, threshold) do case packet do %{received_at: received_at} when not is_nil(received_at) -> @@ -724,6 +732,7 @@ defmodule AprsWeb.MapLive.Index do # Fetch historical packets from the database # Helper function to start historical replay + @spec start_historical_replay(Socket.t()) :: Socket.t() defp start_historical_replay(socket) do # Get time range for historical data now = DateTime.utc_now() @@ -764,6 +773,7 @@ defmodule AprsWeb.MapLive.Index do end # Fetch historical packets from the database + @spec fetch_historical_packets(list(), DateTime.t(), DateTime.t()) :: [struct()] defp fetch_historical_packets(bounds, start_time, end_time) do # Force start_time to be at most 1 hour ago one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) @@ -790,6 +800,7 @@ defmodule AprsWeb.MapLive.Index do Enum.sort_by(packets, fn packet -> packet.received_at end) end + @spec has_position_data?(map() | struct()) :: boolean() defp has_position_data?(packet) do case packet.data_extended do %MicE{} = mic_e -> @@ -806,6 +817,7 @@ defmodule AprsWeb.MapLive.Index do end end + @spec within_bounds?(map() | struct(), map()) :: boolean() defp within_bounds?(packet, bounds) do {lat, lng} = get_coordinates(packet) @@ -830,6 +842,7 @@ defmodule AprsWeb.MapLive.Index do end end + @spec get_coordinates(map() | struct()) :: {number() | nil, number() | nil} defp get_coordinates(packet) do case packet.data_extended do %MicE{} = mic_e -> @@ -855,6 +868,7 @@ defmodule AprsWeb.MapLive.Index do end end + @spec build_packet_data(map() | struct()) :: map() | nil defp build_packet_data(packet) do data_extended = build_data_extended(packet.data_extended) {lat, lng} = get_coordinates(packet) @@ -865,6 +879,7 @@ defmodule AprsWeb.MapLive.Index do end end + @spec build_packet_map(map() | struct(), number(), number(), map() | nil) :: map() defp build_packet_map(packet, lat, lng, data_extended) do {final_table_id, final_symbol_code} = get_validated_symbol(packet, data_extended) callsign = generate_callsign(packet) @@ -886,6 +901,7 @@ defmodule AprsWeb.MapLive.Index do } end + @spec get_validated_symbol(map() | struct(), map() | nil) :: {String.t(), String.t()} defp get_validated_symbol(packet, data_extended) do symbol_table_id = get_symbol_table_id(packet, data_extended) symbol_code = get_symbol_code(packet, data_extended) @@ -897,14 +913,17 @@ defmodule AprsWeb.MapLive.Index do end end + @spec get_symbol_table_id(map() | struct(), map() | nil) :: String.t() defp get_symbol_table_id(packet, data_extended) do get_in(data_extended, ["symbol_table_id"]) || packet.symbol_table_id || "/" end + @spec get_symbol_code(map() | struct(), map() | nil) :: String.t() defp get_symbol_code(packet, data_extended) do get_in(data_extended, ["symbol_code"]) || packet.symbol_code || ">" end + @spec generate_callsign(map() | struct()) :: String.t() defp generate_callsign(packet) do if packet.ssid && packet.ssid != "" do "#{packet.base_callsign}-#{packet.ssid}" @@ -916,8 +935,10 @@ defmodule AprsWeb.MapLive.Index do # Get IP location from external service # Get location from IP using ip-api.com + @spec get_ip_location(String.t() | nil) :: {float(), float()} | nil defp get_ip_location(nil), do: nil + @spec get_ip_location(String.t()) :: {float(), float()} | nil defp get_ip_location(ip) do url = "#{@ip_api_url}#{ip}" @@ -966,11 +987,13 @@ defmodule AprsWeb.MapLive.Index do end end + @spec build_data_extended(nil | MicE.t() | map()) :: nil | map() defp build_data_extended(nil), do: nil defp build_data_extended(%MicE{} = mic_e), do: build_mice_data_extended(mic_e) defp build_data_extended(data_extended), do: build_map_data_extended(data_extended) + @spec build_mice_data_extended(MicE.t()) :: map() defp build_mice_data_extended(mic_e) do lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0 + mic_e.lat_fractional / 6000.0 lat = if mic_e.lat_direction == :south, do: -lat, else: lat @@ -990,6 +1013,7 @@ defmodule AprsWeb.MapLive.Index do end end + @spec build_map_data_extended(map()) :: map() defp build_map_data_extended(data_extended) do %{ "latitude" => data_extended[:latitude], @@ -1002,9 +1026,11 @@ defmodule AprsWeb.MapLive.Index do end # Helper function to convert string or float to float + @spec to_float(number() | String.t()) :: float() defp to_float(value) when is_float(value), do: value defp to_float(value) when is_integer(value), do: value * 1.0 + @spec to_float(String.t()) :: float() defp to_float(value) when is_binary(value) do case Float.parse(value) do {float_val, _} -> float_val diff --git a/lib/parser.ex b/lib/parser.ex index ffa516c..41c997c 100644 --- a/lib/parser.ex +++ b/lib/parser.ex @@ -9,6 +9,22 @@ defmodule Parser do require Logger + @type packet :: %{ + id: String.t(), + sender: String.t(), + path: String.t(), + destination: String.t(), + information_field: String.t(), + data_type: atom(), + base_callsign: String.t(), + ssid: String.t(), + data_extended: map() | nil, + received_at: DateTime.t() + } + + @type parse_result :: {:ok, packet()} | {:error, atom() | String.t()} + + @spec parse(String.t()) :: parse_result() def parse(message) do with {:ok, [sender, path, data]} <- split_packet(message), {:ok, [base_callsign, ssid]} <- parse_callsign(sender), @@ -50,7 +66,8 @@ defmodule Parser do end # Safely split packet into components - defp split_packet(message) do + @spec split_packet(String.t()) :: {:ok, [String.t()]} | {:error, String.t()} + def split_packet(message) do case String.split(message, [">", ":"], parts: 3) do [sender, path, data] when byte_size(sender) > 0 and byte_size(path) > 0 -> {:ok, [sender, path, data]} @@ -61,7 +78,8 @@ defmodule Parser do end # Safely split path into destination and digipeater path - defp split_path(path) do + @spec split_path(String.t()) :: {:ok, [String.t()]} | {:error, String.t()} + def split_path(path) do case String.split(path, ",", parts: 2) do [destination, digi_path] -> {:ok, [destination, digi_path]} @@ -75,13 +93,15 @@ defmodule Parser do end # Safe version of parse_datatype that returns {:ok, type} or {:error, reason} - defp parse_datatype_safe(data) do + @spec parse_datatype_safe(String.t()) :: {:ok, atom()} | {:error, String.t()} + def parse_datatype_safe(data) do case String.first(data) do nil -> {:error, "Empty data field"} first_char -> {:ok, parse_datatype(first_char)} end end + @spec parse_callsign(String.t()) :: {:ok, [String.t()]} | {:error, String.t()} def parse_callsign(callsign) do cond do not is_binary(callsign) -> @@ -107,6 +127,7 @@ defmodule Parser do # first 40 characters of the message. I'm not going to deal with that # weird case right now. It seems like its for a specific type of old # TNC hardware that probably doesn't even exist anymore. + @spec parse_datatype(String.t()) :: atom() def parse_datatype(datatype) when datatype == ":", do: :message def parse_datatype(datatype) when datatype == ">", do: :status def parse_datatype(datatype) when datatype == "!", do: :position @@ -133,6 +154,7 @@ defmodule Parser do def parse_datatype(_datatype), do: :unknown_datatype + @spec parse_data(atom(), String.t(), String.t()) :: map() | nil def parse_data(:mic_e, destination, data), do: parse_mic_e(destination, data) def parse_data(:mic_e_old, destination, data), do: parse_mic_e(destination, data) @@ -172,7 +194,7 @@ defmodule Parser do } _ -> - parse_position_with_timestamp(false, data) + parse_position_with_timestamp(false, data, :timestamped_position) end end @@ -226,6 +248,7 @@ defmodule Parser do def parse_data(_type, _destination, _data), do: nil + @spec parse_position_with_datetime_and_weather(boolean(), String.t(), String.t()) :: map() def parse_position_with_datetime_and_weather(aprs_messaging?, date_time_position_data, weather_report) do case date_time_position_data do <> -> @@ -260,6 +283,7 @@ defmodule Parser do end end + @spec decode_compressed_position(binary()) :: map() def decode_compressed_position( <<"/", latitude::binary-size(4), longitude::binary-size(4), symbol_code::binary-size(1), _cs::binary-size(2), _compression_type::binary-size(2), _rest::binary>> @@ -274,11 +298,13 @@ defmodule Parser do } end + @spec convert_to_base91(binary()) :: integer() 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 parse_position_without_timestamp(boolean(), String.t()) :: map() def parse_position_without_timestamp(aprs_messaging?, position_data) do case position_data do <> + <>, + _data_type ) do case validate_position_data(latitude, longitude) do {:ok, {lat, lon}} -> @@ -395,6 +423,7 @@ defmodule Parser do } end + @spec parse_position_with_timestamp(boolean(), binary()) :: map() def parse_position_with_timestamp(_aprs_messaging?, <<"/", _::binary>>) do %{ data_type: :timestamped_position_error, @@ -410,6 +439,7 @@ defmodule Parser do } end + @spec parse_mic_e(String.t(), String.t()) :: MicE.t() | map() def parse_mic_e(destination_field, information_field) do # Logger.debug("MIC-E: " <> destination_field <> " :: " <> information_field) # Mic-E is kind of a nutty compression scheme, APRS packs additional @@ -443,6 +473,7 @@ defmodule Parser do } end + @spec parse_mic_e(binary()) :: map() def parse_mic_e(data) do case data do <>) when c in ?0..?9, do: [c - ?0, 0, nil] def parse_mic_e_digit(<>) when c in ?A..?J, do: [c - ?A, 1, :custom] def parse_mic_e_digit(<>) when c in ?P..?Y, do: [c - ?P, 1, :standard] @@ -476,6 +508,7 @@ defmodule Parser do def parse_mic_e_digit(_c), do: [:unknown, :unknown, :unknown] + @spec parse_mic_e_destination(String.t()) :: map() def parse_mic_e_destination(destination_field) do digits = destination_field @@ -617,6 +650,7 @@ defmodule Parser do } end + @spec parse_manufacturer(String.t(), String.t(), String.t()) :: String.t() def parse_manufacturer(" ", _s2, _s3), do: "Original MIC-E" def parse_manufacturer(">", _s2, "^"), do: "Kenwood TH-D74" def parse_manufacturer(">", _s2, _s3), do: "Kenwood TH-D74A" @@ -643,6 +677,7 @@ defmodule Parser do def parse_manufacturer(_s1, "~", _s3), do: "Other" def parse_manufacturer(_symbol1, _symbol2, _symbol3), do: "Unknown" + @spec find_matches(Regex.t(), String.t()) :: [String.t()] defp find_matches(regex, text) do case Regex.names(regex) do [] -> @@ -657,11 +692,13 @@ defmodule Parser do end end + @spec convert_compressed_lat(binary()) :: float() def convert_compressed_lat(lat) do [l1, l2, l3, l4] = to_charlist(lat) 90 - ((l1 - 33) * 91 ** 3 + (l2 - 33) * 91 ** 2 + (l3 - 33) * 91 + l4 - 33) / 380_926 end + @spec convert_compressed_lon(binary()) :: float() def convert_compressed_lon(lon) do [l1, l2, l3, l4] = to_charlist(lon) -180 + ((l1 - 33) * 91 ** 3 + (l2 - 33) * 91 ** 2 + (l3 - 33) * 91 + l4 - 33) / 190_463 @@ -700,6 +737,7 @@ defmodule Parser do } end + @spec parse_status(String.t()) :: map() def parse_status(data) do %{ status_text: data, @@ -761,6 +799,7 @@ defmodule Parser do ) end + @spec parse_object(String.t()) :: map() def parse_object(data) do %{ raw_data: data, @@ -769,6 +808,7 @@ defmodule Parser do end # Item Report parsing + @spec parse_item(binary()) :: map() def parse_item(<>) when item_indicator in [?%, ?)] do # Items can have up to 9 character names, followed by ! for position or _ for killed case Regex.run(~r/^(.{1,9})([\!\_])(.*)$/, item_name_and_data) do @@ -856,6 +896,7 @@ defmodule Parser do ) end + @spec parse_weather(String.t()) :: map() def parse_weather(data) do weather_data = parse_weather_data(data) @@ -1065,6 +1106,7 @@ defmodule Parser do end end + @spec parse_telemetry(String.t()) :: map() def parse_telemetry(data) do %{ raw_data: data, @@ -1146,6 +1188,7 @@ defmodule Parser do } end + @spec parse_station_capabilities(String.t()) :: map() def parse_station_capabilities(data) do %{ capabilities: data, @@ -1162,6 +1205,7 @@ defmodule Parser do } end + @spec parse_query(String.t()) :: map() def parse_query(data) do %{ query_data: data, @@ -1183,6 +1227,7 @@ defmodule Parser do ) end + @spec parse_user_defined(String.t()) :: map() def parse_user_defined(data) do %{ user_data: data, @@ -1241,6 +1286,7 @@ defmodule Parser do end end + @spec parse_third_party_traffic(String.t()) :: map() def parse_third_party_traffic(data) do %{ third_party_data: data, @@ -1281,6 +1327,7 @@ defmodule Parser do end end + @spec parse_phg_data(String.t()) :: map() def parse_phg_data(data) do %{ phg_data: data, @@ -1361,6 +1408,7 @@ defmodule Parser do } end + @spec parse_peet_logging(String.t()) :: map() def parse_peet_logging(data) do %{ peet_data: data, @@ -1376,6 +1424,7 @@ defmodule Parser do } end + @spec parse_invalid_test_data(String.t()) :: map() def parse_invalid_test_data(data) do %{ test_data: data, @@ -1408,6 +1457,7 @@ defmodule Parser do end # Validate timestamp format + @spec validate_timestamp(String.t()) :: String.t() | nil defp validate_timestamp(time) when byte_size(time) == 7 do if Regex.match?(~r/^\d{6}[hz\/]$/, time) do time @@ -1416,7 +1466,8 @@ defmodule Parser do end end - defp validate_timestamp(time), do: time + @spec validate_timestamp(any()) :: nil + defp validate_timestamp(_), do: nil defp extract_timestamp(weather_data) do case Regex.run(~r/^(\d{6}[hz\/])/, weather_data) do diff --git a/lib/types/position.ex b/lib/types/position.ex index af2dccc..f85286e 100644 --- a/lib/types/position.ex +++ b/lib/types/position.ex @@ -4,6 +4,19 @@ defmodule Parser.Types.Position do """ 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, @@ -13,6 +26,7 @@ defmodule Parser.Types.Position do 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") @@ -49,10 +63,12 @@ defmodule Parser.Types.Position do 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 @@ -90,6 +106,7 @@ defmodule Parser.Types.Position do # |> Kernel.*(60) # |> Float.round(2) + @spec convert_fractional(String.t()) :: float() defp convert_fractional(fractional), do: fractional |> String.trim() |> String.pad_leading(4, "0") |> String.to_float() |> Kernel.*(60) end diff --git a/test/parser/parser_test.exs b/test/parser/parser_test.exs index 5d07ec3..2abc9f3 100644 --- a/test/parser/parser_test.exs +++ b/test/parser/parser_test.exs @@ -164,7 +164,7 @@ defmodule Parser.ParserTest do assert result.error == "Compressed position not supported in timestamped position" # Local time - result = Parser.parse_data(:timestamped_position, "APRS", "@092345/4903.50N/07201.75W>") + result = Parser.parse_data(:timestamped_position, "APRS", "092345/4903.50N/07201.75W>") assert result.data_type == :position assert result.time == "092345/" @@ -297,16 +297,16 @@ defmodule Parser.ParserTest do test "handles various timestamp errors" do # Invalid hour (>23) - result = Parser.parse_position_with_timestamp(false, "@252345z4903.50N/07201.75W>") + 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>") + 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>") + result = Parser.parse_position_with_timestamp(false, "092361z4903.50N/07201.75W>", :timestamped_position) assert result.data_type == :position # Wrong format/length @@ -316,7 +316,7 @@ defmodule Parser.ParserTest do test "handles MDHM timestamp format errors" do # Invalid MDHM format - result = Parser.parse_position_with_timestamp(false, "@9999999/4903.50N/07201.75W>") + result = Parser.parse_position_with_timestamp(false, "9999999/4903.50N/07201.75W>", :timestamped_position) assert result.data_type == :position end end