add more dialyzer types

This commit is contained in:
Graham McIntire 2025-06-18 11:09:45 -05:00
parent 86548fbe19
commit 4b3d9a87ad
No known key found for this signature in database
15 changed files with 227 additions and 21 deletions

View file

@ -15,6 +15,7 @@ defmodule Aprs.BadPacket do
end end
@doc false @doc false
@spec changeset(%Aprs.BadPacket{}, map()) :: Ecto.Changeset.t()
def changeset(bad_packet, attrs) do def changeset(bad_packet, attrs) do
bad_packet bad_packet
|> cast(attrs, [:raw_packet, :error_message, :error_type, :attempted_at]) |> cast(attrs, [:raw_packet, :error_message, :error_type, :attempted_at])
@ -24,6 +25,7 @@ defmodule Aprs.BadPacket do
@doc """ @doc """
Returns recent bad packets, ordered by attempted_at descending 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 def recent(query \\ __MODULE__, limit \\ 100) do
from(b in query, from(b in query,
order_by: [desc: b.attempted_at], order_by: [desc: b.attempted_at],
@ -34,6 +36,7 @@ defmodule Aprs.BadPacket do
@doc """ @doc """
Returns bad packets by error type 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 def by_error_type(query \\ __MODULE__, error_type) do
from(b in query, from(b in query,
where: b.error_type == ^error_type where: b.error_type == ^error_type
@ -43,6 +46,7 @@ defmodule Aprs.BadPacket do
@doc """ @doc """
Returns count of bad packets in the last N hours Returns count of bad packets in the last N hours
""" """
@spec count_recent(pos_integer()) :: Ecto.Query.t()
def count_recent(hours \\ 24) do def count_recent(hours \\ 24) do
cutoff = DateTime.add(DateTime.utc_now(), -hours * 3600, :second) cutoff = DateTime.add(DateTime.utc_now(), -hours * 3600, :second)

View file

@ -1,8 +1,12 @@
defmodule Aprs.Convert do defmodule Aprs.Convert do
@moduledoc false @moduledoc false
@spec wind(number(), :ultimeter, :mph) :: float()
def wind(speed, :ultimeter, :mph), do: speed * 0.0621371192 def wind(speed, :ultimeter, :mph), do: speed * 0.0621371192
@spec temp(number(), :ultimeter, :f) :: float()
def temp(value, :ultimeter, :f), do: value * 0.1 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) def speed(value, :knots, :mph), do: Float.round(value * 1.15077945, 2)
end end

View file

@ -17,6 +17,7 @@ defmodule Aprs.DataExtended do
end end
@doc false @doc false
@spec changeset(%Aprs.DataExtended{}, map()) :: Ecto.Changeset.t()
def changeset(%DataExtended{} = data_extended, attrs) do def changeset(%DataExtended{} = data_extended, attrs) do
data_extended data_extended
|> cast(attrs, [ |> cast(attrs, [

View file

@ -7,6 +7,8 @@ defmodule Aprs.EncodingUtils do
to web clients. to web clients.
""" """
alias Parser.Types.MicE
@doc """ @doc """
Sanitizes a binary to ensure it can be safely JSON encoded. 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>>) iex> Aprs.EncodingUtils.sanitize_string(<<72, 101, 108, 108, 111, 211, 87, 111, 114, 108, 100>>)
"HelloWorld" "HelloWorld"
""" """
@spec sanitize_string(binary() | nil | any()) :: binary() | nil | any()
def sanitize_string(binary) when is_binary(binary) do def sanitize_string(binary) when is_binary(binary) do
# Always scrub problematic bytes, even from valid UTF-8 strings # 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 # PostgreSQL rejects null bytes and other control characters even if they're valid UTF-8
@ -34,6 +37,7 @@ defmodule Aprs.EncodingUtils do
@doc """ @doc """
Sanitizes all string fields in an APRS packet to ensure safe JSON encoding. 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 def sanitize_packet(%Aprs.Packet{} = packet) do
%{ %{
packet packet
@ -51,13 +55,14 @@ defmodule Aprs.EncodingUtils do
@doc """ @doc """
Sanitizes string fields in the data_extended structure. 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(nil), do: nil
def sanitize_data_extended(%{comment: comment} = data_extended) when is_map(data_extended) do def sanitize_data_extended(%{comment: comment} = data_extended) when is_map(data_extended) do
%{data_extended | comment: sanitize_string(comment)} %{data_extended | comment: sanitize_string(comment)}
end 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)} %{mic_e | message: sanitize_string(message)}
end end
@ -78,6 +83,7 @@ defmodule Aprs.EncodingUtils do
# Private helper functions # Private helper functions
@spec scrub_problematic_bytes(binary()) :: String.t()
defp scrub_problematic_bytes(binary) do defp scrub_problematic_bytes(binary) do
# Handle both invalid UTF-8 sequences and problematic control characters # Handle both invalid UTF-8 sequences and problematic control characters
cleaned = cleaned =
@ -101,11 +107,13 @@ defmodule Aprs.EncodingUtils do
end end
# Check if byte should be kept (ASCII printable + safe whitespace) # 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 >= 32 and byte <= 126, do: true
defp valid_utf8_byte?(byte) when byte in [9, 10, 13], do: true defp valid_utf8_byte?(byte) when byte in [9, 10, 13], do: true
defp valid_utf8_byte?(_), do: false defp valid_utf8_byte?(_), do: false
# Ensure the final result is valid UTF-8 # Ensure the final result is valid UTF-8
@spec ensure_valid_utf8(binary()) :: binary()
defp ensure_valid_utf8(binary) do defp ensure_valid_utf8(binary) do
if String.valid?(binary) do if String.valid?(binary) do
binary binary
@ -133,6 +141,7 @@ defmodule Aprs.EncodingUtils do
iex> Aprs.EncodingUtils.to_hex(<<72, 101, 108, 108, 111>>) iex> Aprs.EncodingUtils.to_hex(<<72, 101, 108, 108, 111>>)
"48656C6C6F" "48656C6C6F"
""" """
@spec to_hex(binary()) :: String.t()
def to_hex(binary) when is_binary(binary) do def to_hex(binary) when is_binary(binary) do
binary binary
|> :binary.bin_to_list() |> :binary.bin_to_list()
@ -151,6 +160,7 @@ defmodule Aprs.EncodingUtils do
iex> Aprs.EncodingUtils.encoding_info(<<72, 101, 211, 108, 111>>) iex> Aprs.EncodingUtils.encoding_info(<<72, 101, 211, 108, 111>>)
%{valid_utf8: false, byte_count: 5, invalid_at: 2} %{valid_utf8: false, byte_count: 5, invalid_at: 2}
""" """
@spec encoding_info(binary()) :: map()
def encoding_info(binary) when is_binary(binary) do def encoding_info(binary) when is_binary(binary) do
valid = String.valid?(binary) valid = String.valid?(binary)
byte_count = byte_size(binary) byte_count = byte_size(binary)
@ -169,6 +179,7 @@ defmodule Aprs.EncodingUtils do
end end
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(<<>>, _pos), do: nil
defp find_invalid_byte_position(binary, pos) do defp find_invalid_byte_position(binary, pos) do

View file

@ -9,6 +9,22 @@ defmodule Aprs.Is do
@aprs_timeout 60 * 1000 @aprs_timeout 60 * 1000
@keepalive_interval 20 * 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 def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__) GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end end
@ -139,6 +155,7 @@ defmodule Aprs.Is do
# Server methods # 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 defp connect_to_aprs_is(server, port) do
# Additional safeguard: prevent connections in test environment # Additional safeguard: prevent connections in test environment
if Application.get_env(:aprs, :env) == :test or if Application.get_env(:aprs, :env) == :test or
@ -152,6 +169,7 @@ defmodule Aprs.Is do
end end
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 defp send_login_string(socket, aprs_user_id, aprs_passcode, filter) do
login_string = login_string =
"user #{aprs_user_id} pass #{aprs_passcode} vers aprs.me 0.1 filter #{filter}\r\n" "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) :gen_tcp.send(socket, login_string)
end end
@spec create_timer(non_neg_integer()) :: reference()
defp create_timer(timeout) do defp create_timer(timeout) do
Process.send_after(self(), :aprs_no_message_timeout, timeout) Process.send_after(self(), :aprs_no_message_timeout, timeout)
end end
@spec create_keepalive_timer(non_neg_integer()) :: reference()
defp create_keepalive_timer(interval) do defp create_keepalive_timer(interval) do
Process.send_after(self(), :send_keepalive, interval) Process.send_after(self(), :send_keepalive, interval)
end end
@ -216,7 +236,9 @@ defmodule Aprs.Is do
end end
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 # Cancel the previous timer
Process.cancel_timer(state.timer) Process.cancel_timer(state.timer)
@ -225,7 +247,7 @@ defmodule Aprs.Is do
packet_stats = update_packet_stats(state.packet_stats, current_time) packet_stats = update_packet_stats(state.packet_stats, current_time)
# Append new packet data to buffer # Append new packet data to buffer
buffer = state.buffer <> packet buffer = state.buffer <> data
# Process complete lines (ending with \r\n or \n) # Process complete lines (ending with \r\n or \n)
{complete_lines, remaining_buffer} = extract_complete_lines(buffer) {complete_lines, remaining_buffer} = extract_complete_lines(buffer)
@ -262,6 +284,7 @@ defmodule Aprs.Is do
end end
# Extract complete lines from buffer, returning {complete_lines, remaining_buffer} # 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 defp extract_complete_lines(buffer) do
# Split by both \r\n and \n to handle different line endings # Split by both \r\n and \n to handle different line endings
parts = String.split(buffer, ~r/\r?\n/, parts: :infinity) parts = String.split(buffer, ~r/\r?\n/, parts: :infinity)
@ -388,6 +411,7 @@ defmodule Aprs.Is do
end end
# Helper to check if a packet has position data worth storing # Helper to check if a packet has position data worth storing
@spec has_position_data?(map()) :: boolean()
defp has_position_data?(packet) do defp has_position_data?(packet) do
require Logger require Logger
@ -425,6 +449,7 @@ defmodule Aprs.Is do
end end
# Helper to validate coordinate values # Helper to validate coordinate values
@spec are_valid_coords?(any(), any()) :: boolean()
defp are_valid_coords?(lat, lon) do defp are_valid_coords?(lat, lon) do
require Logger require Logger
@ -454,6 +479,7 @@ defmodule Aprs.Is do
end end
# Helper to convert various types to float # 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_float(value), do: value
defp to_float(value) when is_integer(value), do: value * 1.0 defp to_float(value) when is_integer(value), do: value * 1.0
defp to_float(%Decimal{} = value), do: Decimal.to_float(value) defp to_float(%Decimal{} = value), do: Decimal.to_float(value)
@ -468,6 +494,7 @@ defmodule Aprs.Is do
defp to_float(_), do: nil defp to_float(_), do: nil
# Normalize data_type to ensure proper storage # 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 defp normalize_data_type(%{data_type: data_type} = attrs) when is_atom(data_type) do
%{attrs | data_type: to_string(data_type)} %{attrs | data_type: to_string(data_type)}
end end
@ -478,6 +505,7 @@ defmodule Aprs.Is do
# :ets.select_count(:aprs_messages, total_spec) # :ets.select_count(:aprs_messages, total_spec)
# end # end
@spec update_packet_stats(map(), integer()) :: map()
defp update_packet_stats(stats, current_time) do defp update_packet_stats(stats, current_time) do
new_total = stats.total_packets + 1 new_total = stats.total_packets + 1
@ -503,10 +531,12 @@ defmodule Aprs.Is do
end end
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_list(server), do: List.to_string(server)
defp server_to_string(server) when is_binary(server), do: server defp server_to_string(server) when is_binary(server), do: server
defp server_to_string(server), do: to_string(server) defp server_to_string(server), do: to_string(server)
@spec default_packet_stats() :: map()
defp default_packet_stats do defp default_packet_stats do
%{ %{
total_packets: 0, total_packets: 0,
@ -519,6 +549,7 @@ defmodule Aprs.Is do
# Helper function to recursively convert structs to maps # Helper function to recursively convert structs to maps
# This handles nested structs that Map.from_struct/1 cannot handle # 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 defp struct_to_map(%{__struct__: struct_type} = struct) do
converted_map = converted_map =
struct struct

View file

@ -61,6 +61,7 @@ defmodule Aprs.Packet do
end end
@doc false @doc false
@spec changeset(%Aprs.Packet{}, map()) :: Ecto.Changeset.t()
def changeset(packet, attrs) do def changeset(packet, attrs) do
# Convert atom data_type to string # Convert atom data_type to string
attrs = normalize_data_type(attrs) attrs = normalize_data_type(attrs)
@ -121,6 +122,7 @@ defmodule Aprs.Packet do
|> maybe_set_has_position() |> maybe_set_has_position()
end end
@spec maybe_create_geometry_from_lat_lon(Ecto.Changeset.t()) :: Ecto.Changeset.t()
defp maybe_create_geometry_from_lat_lon(changeset) do defp maybe_create_geometry_from_lat_lon(changeset) do
lat = get_field(changeset, :lat) || get_change(changeset, :lat) lat = get_field(changeset, :lat) || get_change(changeset, :lat)
lon = get_field(changeset, :lon) || get_change(changeset, :lon) lon = get_field(changeset, :lon) || get_change(changeset, :lon)
@ -207,9 +209,10 @@ defmodule Aprs.Packet do
defp normalize_data_type(attrs), do: attrs defp normalize_data_type(attrs), do: attrs
@doc """ @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. 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 def extract_additional_data(attrs, raw_packet \\ nil) do
data_extended = attrs[:data_extended] || attrs["data_extended"] || %{} data_extended = attrs[:data_extended] || attrs["data_extended"] || %{}
@ -446,6 +449,7 @@ defmodule Aprs.Packet do
@doc """ @doc """
Create a geometry point from lat/lon coordinates. 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 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 if lat >= -90 and lat <= 90 and lon >= -180 and lon <= 180 do
%Geo.Point{coordinates: {lon, lat}, srid: 4326} %Geo.Point{coordinates: {lon, lat}, srid: 4326}
@ -457,18 +461,21 @@ defmodule Aprs.Packet do
@doc """ @doc """
Extract lat/lon from a PostGIS geometry point. 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(%Geo.Point{coordinates: {lon, lat}}), do: {lat, lon}
def extract_coordinates(_), do: {nil, nil} def extract_coordinates(_), do: {nil, nil}
@doc """ @doc """
Get latitude from a packet's location geometry. 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(%__MODULE__{location: %Geo.Point{coordinates: {_lon, lat}}}), do: lat
def lat(_), do: nil def lat(_), do: nil
@doc """ @doc """
Get longitude from a packet's location geometry. 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(%__MODULE__{location: %Geo.Point{coordinates: {lon, _lat}}}), do: lon
def lon(_), do: nil def lon(_), do: nil

View file

@ -19,6 +19,24 @@ defmodule Aprs.PacketReplay do
@default_replay_window_minutes 60 @default_replay_window_minutes 60
@default_replay_speed 5.0 @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 # Client API
@doc """ @doc """
@ -34,6 +52,7 @@ defmodule Aprs.PacketReplay do
* `:limit` - Maximum number of packets to replay (default: 5000) * `:limit` - Maximum number of packets to replay (default: 5000)
* `:with_position` - Only include packets with position data (default: true) * `:with_position` - Only include packets with position data (default: true)
""" """
@spec start_replay(keyword()) :: {:ok, pid()} | {:error, any()}
def start_replay(opts) do def start_replay(opts) do
user_id = Keyword.fetch!(opts, :user_id) user_id = Keyword.fetch!(opts, :user_id)
@ -53,6 +72,7 @@ defmodule Aprs.PacketReplay do
@doc """ @doc """
Stops an active replay session for a user. Stops an active replay session for a user.
""" """
@spec stop_replay(String.t()) :: :ok
def stop_replay(user_id) do def stop_replay(user_id) do
name = via_tuple(user_id) name = via_tuple(user_id)
@ -66,6 +86,7 @@ defmodule Aprs.PacketReplay do
@doc """ @doc """
Pauses an active replay session. Pauses an active replay session.
""" """
@spec pause_replay(String.t()) :: :ok | :already_paused
def pause_replay(user_id) do def pause_replay(user_id) do
name = via_tuple(user_id) name = via_tuple(user_id)
GenServer.call(name, :pause) GenServer.call(name, :pause)
@ -74,6 +95,7 @@ defmodule Aprs.PacketReplay do
@doc """ @doc """
Resumes a paused replay session. Resumes a paused replay session.
""" """
@spec resume_replay(String.t()) :: :ok
def resume_replay(user_id) do def resume_replay(user_id) do
name = via_tuple(user_id) name = via_tuple(user_id)
GenServer.call(name, :resume) GenServer.call(name, :resume)
@ -82,6 +104,7 @@ defmodule Aprs.PacketReplay do
@doc """ @doc """
Changes the replay speed. 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 def set_replay_speed(user_id, speed) when is_number(speed) and speed > 0 do
name = via_tuple(user_id) name = via_tuple(user_id)
GenServer.call(name, {:set_speed, speed}) GenServer.call(name, {:set_speed, speed})
@ -90,6 +113,7 @@ defmodule Aprs.PacketReplay do
@doc """ @doc """
Updates replay filters (region, bounds, callsign, etc.) 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 def update_filters(user_id, filters) when is_list(filters) do
name = via_tuple(user_id) name = via_tuple(user_id)
GenServer.call(name, {:update_filters, filters}) GenServer.call(name, {:update_filters, filters})
@ -98,6 +122,7 @@ defmodule Aprs.PacketReplay do
@doc """ @doc """
Gets information about the current replay session. Gets information about the current replay session.
""" """
@spec get_replay_info(String.t()) :: map()
def get_replay_info(user_id) do def get_replay_info(user_id) do
name = via_tuple(user_id) name = via_tuple(user_id)
GenServer.call(name, :get_info) GenServer.call(name, :get_info)
@ -106,6 +131,7 @@ defmodule Aprs.PacketReplay do
# Server implementation # Server implementation
@impl true @impl true
@spec init(keyword()) :: {:ok, state()}
def init(opts) do def init(opts) do
user_id = Keyword.fetch!(opts, :user_id) user_id = Keyword.fetch!(opts, :user_id)
@ -152,6 +178,7 @@ defmodule Aprs.PacketReplay do
end end
@impl true @impl true
@spec handle_info(any(), state()) :: {:noreply, state()} | {:stop, :normal, state()}
def handle_info(:start_replay, state) do def handle_info(:start_replay, state) do
# Fetch historical packets based on filters # Fetch historical packets based on filters
# Always filter by bounds (visible map area) and limit to packets with position # Always filter by bounds (visible map area) and limit to packets with position
@ -245,6 +272,7 @@ defmodule Aprs.PacketReplay do
end end
@impl true @impl true
@spec handle_call(any(), {pid(), any()}, state()) :: {:reply, any(), state()}
def handle_call(:pause, _from, state) do def handle_call(:pause, _from, state) do
if state.replay_timer do if state.replay_timer do
Process.cancel_timer(state.replay_timer) Process.cancel_timer(state.replay_timer)
@ -345,6 +373,7 @@ defmodule Aprs.PacketReplay do
end end
@impl true @impl true
@spec terminate(any(), state()) :: :ok
def terminate(_reason, state) do def terminate(_reason, state) do
# Clean up any timers # Clean up any timers
if state.replay_timer do if state.replay_timer do
@ -362,10 +391,12 @@ defmodule Aprs.PacketReplay do
# Helper functions # Helper functions
@spec via_tuple(String.t()) :: {:via, Registry, {atom(), String.t()}}
defp via_tuple(user_id) do defp via_tuple(user_id) do
{:via, Registry, {Aprs.ReplayRegistry, "replay:#{user_id}"}} {:via, Registry, {Aprs.ReplayRegistry, "replay:#{user_id}"}}
end end
@spec sanitize_packet_for_transport(any()) :: map()
defp sanitize_packet_for_transport(packet) do defp sanitize_packet_for_transport(packet) do
# Convert to map and ensure all fields are JSON-safe # Convert to map and ensure all fields are JSON-safe
packet packet
@ -374,6 +405,7 @@ defmodule Aprs.PacketReplay do
|> sanitize_map_values() |> sanitize_map_values()
end end
@spec sanitize_map_values(map()) :: map()
defp sanitize_map_values(map) when is_map(map) do defp sanitize_map_values(map) when is_map(map) do
Enum.reduce(map, %{}, fn {k, v}, acc -> Enum.reduce(map, %{}, fn {k, v}, acc ->
Map.put(acc, k, sanitize_value(v)) Map.put(acc, k, sanitize_value(v))

View file

@ -16,6 +16,7 @@ defmodule Aprs.Packets do
## Parameters ## Parameters
* `packet_data` - Map containing packet data to be stored * `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 def store_packet(packet_data) do
require Logger require Logger
@ -114,6 +115,7 @@ defmodule Aprs.Packets do
* `packet_data` - The original packet data that failed to parse * `packet_data` - The original packet data that failed to parse
* `error` - The error that occurred during parsing * `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 def store_bad_packet(packet_data, error) do
error_type = error_type =
case error do case error do
@ -216,6 +218,7 @@ defmodule Aprs.Packets do
@doc """ @doc """
Gets historical packet count for a map area. Gets historical packet count for a map area.
""" """
@spec get_historical_packet_count(map()) :: non_neg_integer()
def get_historical_packet_count(opts \\ %{}) do def get_historical_packet_count(opts \\ %{}) do
base_query = from(p in Packet, select: count(p.id), where: p.has_position == true) 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 defp filter_by_time(query, _), do: query
@doc """ @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. This is used for initial map loading to show only recent packets.
""" """
@spec get_recent_packets(map()) :: [struct()]
def get_recent_packets(opts \\ %{}) do def get_recent_packets(opts \\ %{}) do
# Always limit to the last hour # Always limit to the last hour
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
@ -361,6 +365,7 @@ defmodule Aprs.Packets do
@doc """ @doc """
Gets the total count of stored packets in the database. Gets the total count of stored packets in the database.
""" """
@spec get_total_packet_count() :: non_neg_integer()
def get_total_packet_count do def get_total_packet_count do
Repo.one(from p in Packet, select: count(p.id)) Repo.one(from p in Packet, select: count(p.id))
end end
@ -382,17 +387,20 @@ defmodule Aprs.Packets do
end end
@doc """ @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 This function allows for more granular cleanup operations by specifying
the exact age threshold for packet deletion. the exact age threshold for packet deletion.
@doc \"""
Removes packets older than the specified number of days.
## Parameters ## 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 ## Returns
- Number of packets deleted - 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 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) cutoff_time = DateTime.add(DateTime.utc_now(), -days * 86_400, :second)
@ -481,6 +489,7 @@ defmodule Aprs.Packets do
end end
# Get packets from last hour only - used to initialize the map # Get packets from last hour only - used to initialize the map
@spec get_last_hour_packets() :: [struct()]
def get_last_hour_packets do def get_last_hour_packets do
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)

View file

@ -17,6 +17,7 @@ defmodule Aprs.Passcode do
iex> Aprs.Passcode.generate("W5ISP") iex> Aprs.Passcode.generate("W5ISP")
15748 15748
""" """
@spec generate(String.t()) :: non_neg_integer()
def generate(callsign) when is_binary(callsign) do def generate(callsign) when is_binary(callsign) do
# Split on '-' and take first part, uppercase, and limit to 10 chars # Split on '-' and take first part, uppercase, and limit to 10 chars
realcall = realcall =

View file

@ -25,6 +25,7 @@ defmodule Aprs.Workers.PacketCleanupWorker do
require Logger require Logger
@impl Oban.Worker @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 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") Logger.info("Starting scheduled APRS packet cleanup for packets older than #{days} days")
@ -41,6 +42,7 @@ defmodule Aprs.Workers.PacketCleanupWorker do
:ok :ok
end end
@spec perform(Oban.Job.t()) :: :ok | {:error, String.t()}
def perform(%Oban.Job{args: _args}) do def perform(%Oban.Job{args: _args}) do
Logger.info("Starting scheduled APRS packet cleanup") Logger.info("Starting scheduled APRS packet cleanup")
@ -78,6 +80,7 @@ defmodule Aprs.Workers.PacketCleanupWorker do
## Returns ## Returns
- Number of packets deleted - 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 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") Logger.info("Starting APRS packet cleanup for packets older than #{days} days")

View file

@ -22,6 +22,7 @@ defmodule AprsWeb.Helpers.AprsSymbols do
iex> AprsWeb.Helpers.AprsSymbols.get_sprite_filename("\\") iex> AprsWeb.Helpers.AprsSymbols.get_sprite_filename("\\")
"aprs-symbols-24-1.png" "aprs-symbols-24-1.png"
""" """
@spec get_sprite_filename(String.t()) :: String.t()
def get_sprite_filename(symbol_table_id) do def get_sprite_filename(symbol_table_id) do
case symbol_table_id do case symbol_table_id do
"/" -> "aprs-symbols-24-0.png" "/" -> "aprs-symbols-24-0.png"
@ -34,6 +35,7 @@ defmodule AprsWeb.Helpers.AprsSymbols do
@doc """ @doc """
Get the high-resolution sprite sheet filename for retina displays. 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 def get_sprite_filename_2x(symbol_table_id) do
case symbol_table_id do case symbol_table_id do
"/" -> "aprs-symbols-24-0@2x.png" "/" -> "aprs-symbols-24-0@2x.png"
@ -56,6 +58,7 @@ defmodule AprsWeb.Helpers.AprsSymbols do
iex> AprsWeb.Helpers.AprsSymbols.get_symbol_position("!") iex> AprsWeb.Helpers.AprsSymbols.get_symbol_position("!")
{-216, 0} # ASCII 33, position 1 -> column 1, row 0 {-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 def get_symbol_position(symbol_code) when is_binary(symbol_code) do
# Get first character if string # Get first character if string
char_code = symbol_code |> String.first() |> String.to_charlist() |> List.first() 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("/", ">") 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;" "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 def symbol_css_style(symbol_table_id, symbol_code) do
sprite_file = get_sprite_filename(symbol_table_id) sprite_file = get_sprite_filename(symbol_table_id)
{x, y} = get_symbol_position(symbol_code) {x, y} = get_symbol_position(symbol_code)
@ -105,6 +109,7 @@ defmodule AprsWeb.Helpers.AprsSymbols do
@doc """ @doc """
Generate HTML for an APRS symbol with proper sprite positioning. 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 def symbol_html(symbol_table_id, symbol_code, opts \\ []) do
css_class = Keyword.get(opts, :class, "aprs-symbol") css_class = Keyword.get(opts, :class, "aprs-symbol")
extra_style = Keyword.get(opts, :style, "") 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. 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. 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 def get_symbol_data_attributes(symbol_table_id, symbol_code) do
sprite_file = get_sprite_filename(symbol_table_id) sprite_file = get_sprite_filename(symbol_table_id)
sprite_file_2x = get_sprite_filename_2x(symbol_table_id) sprite_file_2x = get_sprite_filename_2x(symbol_table_id)
@ -137,6 +143,7 @@ defmodule AprsWeb.Helpers.AprsSymbols do
@doc """ @doc """
Get the default symbol for unknown or invalid symbols. Get the default symbol for unknown or invalid symbols.
""" """
@spec default_symbol() :: {String.t(), String.t()}
def default_symbol do def default_symbol do
# Car icon as default # Car icon as default
{"/", ">"} {"/", ">"}
@ -145,6 +152,7 @@ defmodule AprsWeb.Helpers.AprsSymbols do
@doc """ @doc """
Validate if a symbol table ID and code combination is valid. 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 def valid_symbol?(symbol_table_id, symbol_code) do
case {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 -> {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 This is a subset of common symbols - for a complete list, you'd want to
reference the official APRS symbol specification. reference the official APRS symbol specification.
""" """
@spec symbol_description(String.t(), String.t()) :: String.t()
def symbol_description(symbol_table_id, symbol_code) do def symbol_description(symbol_table_id, symbol_code) do
case {symbol_table_id, symbol_code} do case {symbol_table_id, symbol_code} do
# Primary Table (/) # Primary Table (/)

View file

@ -8,6 +8,7 @@ defmodule AprsWeb.MapLive.Index do
alias AprsWeb.Endpoint alias AprsWeb.Endpoint
alias AprsWeb.Helpers.AprsSymbols alias AprsWeb.Helpers.AprsSymbols
alias Parser.Types.MicE alias Parser.Types.MicE
alias Phoenix.LiveView.Socket
@default_center %{lat: 39.8283, lng: -98.5795} @default_center %{lat: 39.8283, lng: -98.5795}
@default_zoom 5 @default_zoom 5
@ -30,6 +31,7 @@ defmodule AprsWeb.MapLive.Index do
{:ok, socket} {:ok, socket}
end end
@spec assign_defaults(Socket.t(), DateTime.t()) :: Socket.t()
defp assign_defaults(socket, one_hour_ago) do defp assign_defaults(socket, one_hour_ago) do
assign(socket, assign(socket,
packets: [], packets: [],
@ -61,6 +63,7 @@ defmodule AprsWeb.MapLive.Index do
) )
end end
@spec maybe_start_geolocation(Socket.t()) :: Socket.t()
defp maybe_start_geolocation(socket) do defp maybe_start_geolocation(socket) do
if Application.get_env(:aprs, :disable_aprs_connection, false) != true do if Application.get_env(:aprs, :disable_aprs_connection, false) != true do
ip = ip =
@ -81,6 +84,8 @@ defmodule AprsWeb.MapLive.Index do
end) end)
end end
end end
socket
end end
defp schedule_timers do defp schedule_timers do
@ -242,6 +247,7 @@ defmodule AprsWeb.MapLive.Index do
{:noreply, socket} {:noreply, socket}
end end
@spec handle_bounds_update(map(), Socket.t()) :: {:noreply, Socket.t()}
defp handle_bounds_update(bounds, socket) do defp handle_bounds_update(bounds, socket) do
# Update the map bounds from the client # Update the map bounds from the client
map_bounds = %{ map_bounds = %{
@ -270,6 +276,7 @@ defmodule AprsWeb.MapLive.Index do
end end
end end
@spec process_bounds_update(map(), Socket.t()) :: Socket.t()
defp process_bounds_update(map_bounds, socket) do defp process_bounds_update(map_bounds, socket) do
# Filter visible packets to only include those within the new bounds and time threshold # Filter visible packets to only include those within the new bounds and time threshold
new_visible_packets = new_visible_packets =
@ -709,6 +716,7 @@ defmodule AprsWeb.MapLive.Index do
end end
# Check if a packet is within the time threshold (not too old) # 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 defp packet_within_time_threshold?(packet, threshold) do
case packet do case packet do
%{received_at: received_at} when not is_nil(received_at) -> %{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 # Fetch historical packets from the database
# Helper function to start historical replay # Helper function to start historical replay
@spec start_historical_replay(Socket.t()) :: Socket.t()
defp start_historical_replay(socket) do defp start_historical_replay(socket) do
# Get time range for historical data # Get time range for historical data
now = DateTime.utc_now() now = DateTime.utc_now()
@ -764,6 +773,7 @@ defmodule AprsWeb.MapLive.Index do
end end
# Fetch historical packets from the database # 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 defp fetch_historical_packets(bounds, start_time, end_time) do
# Force start_time to be at most 1 hour ago # Force start_time to be at most 1 hour ago
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) 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) Enum.sort_by(packets, fn packet -> packet.received_at end)
end end
@spec has_position_data?(map() | struct()) :: boolean()
defp has_position_data?(packet) do defp has_position_data?(packet) do
case packet.data_extended do case packet.data_extended do
%MicE{} = mic_e -> %MicE{} = mic_e ->
@ -806,6 +817,7 @@ defmodule AprsWeb.MapLive.Index do
end end
end end
@spec within_bounds?(map() | struct(), map()) :: boolean()
defp within_bounds?(packet, bounds) do defp within_bounds?(packet, bounds) do
{lat, lng} = get_coordinates(packet) {lat, lng} = get_coordinates(packet)
@ -830,6 +842,7 @@ defmodule AprsWeb.MapLive.Index do
end end
end end
@spec get_coordinates(map() | struct()) :: {number() | nil, number() | nil}
defp get_coordinates(packet) do defp get_coordinates(packet) do
case packet.data_extended do case packet.data_extended do
%MicE{} = mic_e -> %MicE{} = mic_e ->
@ -855,6 +868,7 @@ defmodule AprsWeb.MapLive.Index do
end end
end end
@spec build_packet_data(map() | struct()) :: map() | nil
defp build_packet_data(packet) do defp build_packet_data(packet) do
data_extended = build_data_extended(packet.data_extended) data_extended = build_data_extended(packet.data_extended)
{lat, lng} = get_coordinates(packet) {lat, lng} = get_coordinates(packet)
@ -865,6 +879,7 @@ defmodule AprsWeb.MapLive.Index do
end end
end end
@spec build_packet_map(map() | struct(), number(), number(), map() | nil) :: map()
defp build_packet_map(packet, lat, lng, data_extended) do defp build_packet_map(packet, lat, lng, data_extended) do
{final_table_id, final_symbol_code} = get_validated_symbol(packet, data_extended) {final_table_id, final_symbol_code} = get_validated_symbol(packet, data_extended)
callsign = generate_callsign(packet) callsign = generate_callsign(packet)
@ -886,6 +901,7 @@ defmodule AprsWeb.MapLive.Index do
} }
end end
@spec get_validated_symbol(map() | struct(), map() | nil) :: {String.t(), String.t()}
defp get_validated_symbol(packet, data_extended) do defp get_validated_symbol(packet, data_extended) do
symbol_table_id = get_symbol_table_id(packet, data_extended) symbol_table_id = get_symbol_table_id(packet, data_extended)
symbol_code = get_symbol_code(packet, data_extended) symbol_code = get_symbol_code(packet, data_extended)
@ -897,14 +913,17 @@ defmodule AprsWeb.MapLive.Index do
end end
end end
@spec get_symbol_table_id(map() | struct(), map() | nil) :: String.t()
defp get_symbol_table_id(packet, data_extended) do defp get_symbol_table_id(packet, data_extended) do
get_in(data_extended, ["symbol_table_id"]) || packet.symbol_table_id || "/" get_in(data_extended, ["symbol_table_id"]) || packet.symbol_table_id || "/"
end end
@spec get_symbol_code(map() | struct(), map() | nil) :: String.t()
defp get_symbol_code(packet, data_extended) do defp get_symbol_code(packet, data_extended) do
get_in(data_extended, ["symbol_code"]) || packet.symbol_code || ">" get_in(data_extended, ["symbol_code"]) || packet.symbol_code || ">"
end end
@spec generate_callsign(map() | struct()) :: String.t()
defp generate_callsign(packet) do defp generate_callsign(packet) do
if packet.ssid && packet.ssid != "" do if packet.ssid && packet.ssid != "" do
"#{packet.base_callsign}-#{packet.ssid}" "#{packet.base_callsign}-#{packet.ssid}"
@ -916,8 +935,10 @@ defmodule AprsWeb.MapLive.Index do
# Get IP location from external service # Get IP location from external service
# Get location from IP using ip-api.com # 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 defp get_ip_location(nil), do: nil
@spec get_ip_location(String.t()) :: {float(), float()} | nil
defp get_ip_location(ip) do defp get_ip_location(ip) do
url = "#{@ip_api_url}#{ip}" url = "#{@ip_api_url}#{ip}"
@ -966,11 +987,13 @@ defmodule AprsWeb.MapLive.Index do
end end
end end
@spec build_data_extended(nil | MicE.t() | map()) :: nil | map()
defp build_data_extended(nil), do: nil 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(%MicE{} = mic_e), do: build_mice_data_extended(mic_e)
defp build_data_extended(data_extended), do: build_map_data_extended(data_extended) 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 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 = 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 lat = if mic_e.lat_direction == :south, do: -lat, else: lat
@ -990,6 +1013,7 @@ defmodule AprsWeb.MapLive.Index do
end end
end end
@spec build_map_data_extended(map()) :: map()
defp build_map_data_extended(data_extended) do defp build_map_data_extended(data_extended) do
%{ %{
"latitude" => data_extended[:latitude], "latitude" => data_extended[:latitude],
@ -1002,9 +1026,11 @@ defmodule AprsWeb.MapLive.Index do
end end
# Helper function to convert string or float to float # 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_float(value), do: value
defp to_float(value) when is_integer(value), do: value * 1.0 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 defp to_float(value) when is_binary(value) do
case Float.parse(value) do case Float.parse(value) do
{float_val, _} -> float_val {float_val, _} -> float_val

View file

@ -9,6 +9,22 @@ defmodule Parser do
require Logger 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 def parse(message) do
with {:ok, [sender, path, data]} <- split_packet(message), with {:ok, [sender, path, data]} <- split_packet(message),
{:ok, [base_callsign, ssid]} <- parse_callsign(sender), {:ok, [base_callsign, ssid]} <- parse_callsign(sender),
@ -50,7 +66,8 @@ defmodule Parser do
end end
# Safely split packet into components # 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 case String.split(message, [">", ":"], parts: 3) do
[sender, path, data] when byte_size(sender) > 0 and byte_size(path) > 0 -> [sender, path, data] when byte_size(sender) > 0 and byte_size(path) > 0 ->
{:ok, [sender, path, data]} {:ok, [sender, path, data]}
@ -61,7 +78,8 @@ defmodule Parser do
end end
# Safely split path into destination and digipeater path # 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 case String.split(path, ",", parts: 2) do
[destination, digi_path] -> [destination, digi_path] ->
{:ok, [destination, digi_path]} {:ok, [destination, digi_path]}
@ -75,13 +93,15 @@ defmodule Parser do
end end
# Safe version of parse_datatype that returns {:ok, type} or {:error, reason} # 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 case String.first(data) do
nil -> {:error, "Empty data field"} nil -> {:error, "Empty data field"}
first_char -> {:ok, parse_datatype(first_char)} first_char -> {:ok, parse_datatype(first_char)}
end end
end end
@spec parse_callsign(String.t()) :: {:ok, [String.t()]} | {:error, String.t()}
def parse_callsign(callsign) do def parse_callsign(callsign) do
cond do cond do
not is_binary(callsign) -> 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 # 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 # weird case right now. It seems like its for a specific type of old
# TNC hardware that probably doesn't even exist anymore. # 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: :message
def parse_datatype(datatype) when datatype == ">", do: :status def parse_datatype(datatype) when datatype == ">", do: :status
def parse_datatype(datatype) when datatype == "!", do: :position def parse_datatype(datatype) when datatype == "!", do: :position
@ -133,6 +154,7 @@ defmodule Parser do
def parse_datatype(_datatype), do: :unknown_datatype 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, destination, data), do: parse_mic_e(destination, data)
def parse_data(:mic_e_old, 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
end end
@ -226,6 +248,7 @@ defmodule Parser do
def parse_data(_type, _destination, _data), do: nil 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 def parse_position_with_datetime_and_weather(aprs_messaging?, date_time_position_data, weather_report) do
case date_time_position_data do case date_time_position_data do
<<time::binary-size(7), latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9)>> -> <<time::binary-size(7), latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9)>> ->
@ -260,6 +283,7 @@ defmodule Parser do
end end
end end
@spec decode_compressed_position(binary()) :: map()
def decode_compressed_position( def decode_compressed_position(
<<"/", latitude::binary-size(4), longitude::binary-size(4), symbol_code::binary-size(1), _cs::binary-size(2), <<"/", latitude::binary-size(4), longitude::binary-size(4), symbol_code::binary-size(1), _cs::binary-size(2),
_compression_type::binary-size(2), _rest::binary>> _compression_type::binary-size(2), _rest::binary>>
@ -274,11 +298,13 @@ defmodule Parser do
} }
end end
@spec convert_to_base91(binary()) :: integer()
def convert_to_base91(<<value::binary-size(4)>>) do def convert_to_base91(<<value::binary-size(4)>>) do
[v1, v2, v3, v4] = to_charlist(value) [v1, v2, v3, v4] = to_charlist(value)
(v1 - 33) * 91 * 91 * 91 + (v2 - 33) * 91 * 91 + (v3 - 33) * 91 + v4 (v1 - 33) * 91 * 91 * 91 + (v2 - 33) * 91 * 91 + (v3 - 33) * 91 + v4
end end
@spec parse_position_without_timestamp(boolean(), String.t()) :: map()
def parse_position_without_timestamp(aprs_messaging?, position_data) do def parse_position_without_timestamp(aprs_messaging?, position_data) do
case position_data do case position_data do
<<latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9), symbol_code::binary-size(1), <<latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9), symbol_code::binary-size(1),
@ -346,10 +372,12 @@ defmodule Parser do
end end
end end
@spec parse_position_with_timestamp(boolean(), binary(), atom()) :: map()
def parse_position_with_timestamp( def parse_position_with_timestamp(
aprs_messaging?, aprs_messaging?,
<<_dti::binary-size(1), time::binary-size(7), latitude::binary-size(8), sym_table_id::binary-size(1), <<time::binary-size(7), latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9),
longitude::binary-size(9), symbol_code::binary-size(1), comment::binary>> symbol_code::binary-size(1), comment::binary>>,
_data_type
) do ) do
case validate_position_data(latitude, longitude) do case validate_position_data(latitude, longitude) do
{:ok, {lat, lon}} -> {:ok, {lat, lon}} ->
@ -395,6 +423,7 @@ defmodule Parser do
} }
end end
@spec parse_position_with_timestamp(boolean(), binary()) :: map()
def parse_position_with_timestamp(_aprs_messaging?, <<"/", _::binary>>) do def parse_position_with_timestamp(_aprs_messaging?, <<"/", _::binary>>) do
%{ %{
data_type: :timestamped_position_error, data_type: :timestamped_position_error,
@ -410,6 +439,7 @@ defmodule Parser do
} }
end end
@spec parse_mic_e(String.t(), String.t()) :: MicE.t() | map()
def parse_mic_e(destination_field, information_field) do def parse_mic_e(destination_field, information_field) do
# Logger.debug("MIC-E: " <> destination_field <> " :: " <> information_field) # Logger.debug("MIC-E: " <> destination_field <> " :: " <> information_field)
# Mic-E is kind of a nutty compression scheme, APRS packs additional # Mic-E is kind of a nutty compression scheme, APRS packs additional
@ -443,6 +473,7 @@ defmodule Parser do
} }
end end
@spec parse_mic_e(binary()) :: map()
def parse_mic_e(data) do def parse_mic_e(data) do
case data do case data do
<<dti::binary-size(1), latitude::binary-size(6), longitude::binary-size(7), symbol_code::binary-size(1), <<dti::binary-size(1), latitude::binary-size(6), longitude::binary-size(7), symbol_code::binary-size(1),
@ -466,6 +497,7 @@ defmodule Parser do
end end
end end
@spec parse_mic_e_digit(binary()) :: [integer() | atom() | nil]
def parse_mic_e_digit(<<c>>) when c in ?0..?9, do: [c - ?0, 0, nil] def parse_mic_e_digit(<<c>>) when c in ?0..?9, do: [c - ?0, 0, nil]
def parse_mic_e_digit(<<c>>) when c in ?A..?J, do: [c - ?A, 1, :custom] def parse_mic_e_digit(<<c>>) when c in ?A..?J, do: [c - ?A, 1, :custom]
def parse_mic_e_digit(<<c>>) when c in ?P..?Y, do: [c - ?P, 1, :standard] def parse_mic_e_digit(<<c>>) 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] 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 def parse_mic_e_destination(destination_field) do
digits = digits =
destination_field destination_field
@ -617,6 +650,7 @@ defmodule Parser do
} }
end 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, _s3), do: "Original MIC-E"
def parse_manufacturer(">", _s2, "^"), do: "Kenwood TH-D74" def parse_manufacturer(">", _s2, "^"), do: "Kenwood TH-D74"
def parse_manufacturer(">", _s2, _s3), do: "Kenwood TH-D74A" 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(_s1, "~", _s3), do: "Other"
def parse_manufacturer(_symbol1, _symbol2, _symbol3), do: "Unknown" def parse_manufacturer(_symbol1, _symbol2, _symbol3), do: "Unknown"
@spec find_matches(Regex.t(), String.t()) :: [String.t()]
defp find_matches(regex, text) do defp find_matches(regex, text) do
case Regex.names(regex) do case Regex.names(regex) do
[] -> [] ->
@ -657,11 +692,13 @@ defmodule Parser do
end end
end end
@spec convert_compressed_lat(binary()) :: float()
def convert_compressed_lat(lat) do def convert_compressed_lat(lat) do
[l1, l2, l3, l4] = to_charlist(lat) [l1, l2, l3, l4] = to_charlist(lat)
90 - ((l1 - 33) * 91 ** 3 + (l2 - 33) * 91 ** 2 + (l3 - 33) * 91 + l4 - 33) / 380_926 90 - ((l1 - 33) * 91 ** 3 + (l2 - 33) * 91 ** 2 + (l3 - 33) * 91 + l4 - 33) / 380_926
end end
@spec convert_compressed_lon(binary()) :: float()
def convert_compressed_lon(lon) do def convert_compressed_lon(lon) do
[l1, l2, l3, l4] = to_charlist(lon) [l1, l2, l3, l4] = to_charlist(lon)
-180 + ((l1 - 33) * 91 ** 3 + (l2 - 33) * 91 ** 2 + (l3 - 33) * 91 + l4 - 33) / 190_463 -180 + ((l1 - 33) * 91 ** 3 + (l2 - 33) * 91 ** 2 + (l3 - 33) * 91 + l4 - 33) / 190_463
@ -700,6 +737,7 @@ defmodule Parser do
} }
end end
@spec parse_status(String.t()) :: map()
def parse_status(data) do def parse_status(data) do
%{ %{
status_text: data, status_text: data,
@ -761,6 +799,7 @@ defmodule Parser do
) )
end end
@spec parse_object(String.t()) :: map()
def parse_object(data) do def parse_object(data) do
%{ %{
raw_data: data, raw_data: data,
@ -769,6 +808,7 @@ defmodule Parser do
end end
# Item Report parsing # Item Report parsing
@spec parse_item(binary()) :: map()
def parse_item(<<item_indicator, item_name_and_data::binary>>) when item_indicator in [?%, ?)] do def parse_item(<<item_indicator, item_name_and_data::binary>>) when item_indicator in [?%, ?)] do
# Items can have up to 9 character names, followed by ! for position or _ for killed # 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 case Regex.run(~r/^(.{1,9})([\!\_])(.*)$/, item_name_and_data) do
@ -856,6 +896,7 @@ defmodule Parser do
) )
end end
@spec parse_weather(String.t()) :: map()
def parse_weather(data) do def parse_weather(data) do
weather_data = parse_weather_data(data) weather_data = parse_weather_data(data)
@ -1065,6 +1106,7 @@ defmodule Parser do
end end
end end
@spec parse_telemetry(String.t()) :: map()
def parse_telemetry(data) do def parse_telemetry(data) do
%{ %{
raw_data: data, raw_data: data,
@ -1146,6 +1188,7 @@ defmodule Parser do
} }
end end
@spec parse_station_capabilities(String.t()) :: map()
def parse_station_capabilities(data) do def parse_station_capabilities(data) do
%{ %{
capabilities: data, capabilities: data,
@ -1162,6 +1205,7 @@ defmodule Parser do
} }
end end
@spec parse_query(String.t()) :: map()
def parse_query(data) do def parse_query(data) do
%{ %{
query_data: data, query_data: data,
@ -1183,6 +1227,7 @@ defmodule Parser do
) )
end end
@spec parse_user_defined(String.t()) :: map()
def parse_user_defined(data) do def parse_user_defined(data) do
%{ %{
user_data: data, user_data: data,
@ -1241,6 +1286,7 @@ defmodule Parser do
end end
end end
@spec parse_third_party_traffic(String.t()) :: map()
def parse_third_party_traffic(data) do def parse_third_party_traffic(data) do
%{ %{
third_party_data: data, third_party_data: data,
@ -1281,6 +1327,7 @@ defmodule Parser do
end end
end end
@spec parse_phg_data(String.t()) :: map()
def parse_phg_data(data) do def parse_phg_data(data) do
%{ %{
phg_data: data, phg_data: data,
@ -1361,6 +1408,7 @@ defmodule Parser do
} }
end end
@spec parse_peet_logging(String.t()) :: map()
def parse_peet_logging(data) do def parse_peet_logging(data) do
%{ %{
peet_data: data, peet_data: data,
@ -1376,6 +1424,7 @@ defmodule Parser do
} }
end end
@spec parse_invalid_test_data(String.t()) :: map()
def parse_invalid_test_data(data) do def parse_invalid_test_data(data) do
%{ %{
test_data: data, test_data: data,
@ -1408,6 +1457,7 @@ defmodule Parser do
end end
# Validate timestamp format # Validate timestamp format
@spec validate_timestamp(String.t()) :: String.t() | nil
defp validate_timestamp(time) when byte_size(time) == 7 do defp validate_timestamp(time) when byte_size(time) == 7 do
if Regex.match?(~r/^\d{6}[hz\/]$/, time) do if Regex.match?(~r/^\d{6}[hz\/]$/, time) do
time time
@ -1416,7 +1466,8 @@ defmodule Parser do
end end
end end
defp validate_timestamp(time), do: time @spec validate_timestamp(any()) :: nil
defp validate_timestamp(_), do: nil
defp extract_timestamp(weather_data) do defp extract_timestamp(weather_data) do
case Regex.run(~r/^(\d{6}[hz\/])/, weather_data) do case Regex.run(~r/^(\d{6}[hz\/])/, weather_data) do

View file

@ -4,6 +4,19 @@ defmodule Parser.Types.Position do
""" """
require Logger 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, defstruct lat_degrees: 0,
lat_minutes: 0, lat_minutes: 0,
lat_fractional: 0, lat_fractional: 0,
@ -13,6 +26,7 @@ defmodule Parser.Types.Position do
lon_minutes: 0, lon_minutes: 0,
lon_fractional: 0 lon_fractional: 0
@spec from_aprs(String.t(), String.t()) :: %{latitude: float(), longitude: float()}
def from_aprs(aprs_latitude, aprs_longitude) do def from_aprs(aprs_latitude, aprs_longitude) do
aprs_latitude = aprs_latitude |> String.replace(" ", "0") |> String.pad_leading(9, "0") aprs_latitude = aprs_latitude |> String.replace(" ", "0") |> String.pad_leading(9, "0")
aprs_longitude = aprs_longitude |> String.replace(" ", "0") |> String.pad_leading(9, "0") aprs_longitude = aprs_longitude |> String.replace(" ", "0") |> String.pad_leading(9, "0")
@ -49,10 +63,12 @@ defmodule Parser.Types.Position do
end end
end end
@spec from_decimal(number(), number()) :: %{latitude: number(), longitude: number()}
def from_decimal(latitude, longitude) do def from_decimal(latitude, longitude) do
%{latitude: latitude, longitude: longitude} %{latitude: latitude, longitude: longitude}
end end
@spec convert_garbage_to_zero(String.t()) :: String.t()
defp convert_garbage_to_zero(value) do defp convert_garbage_to_zero(value) do
_ = String.to_float(value) _ = String.to_float(value)
value value
@ -90,6 +106,7 @@ defmodule Parser.Types.Position do
# |> Kernel.*(60) # |> Kernel.*(60)
# |> Float.round(2) # |> Float.round(2)
@spec convert_fractional(String.t()) :: float()
defp convert_fractional(fractional), defp convert_fractional(fractional),
do: fractional |> String.trim() |> String.pad_leading(4, "0") |> String.to_float() |> Kernel.*(60) do: fractional |> String.trim() |> String.pad_leading(4, "0") |> String.to_float() |> Kernel.*(60)
end end

View file

@ -164,7 +164,7 @@ defmodule Parser.ParserTest do
assert result.error == "Compressed position not supported in timestamped position" assert result.error == "Compressed position not supported in timestamped position"
# Local time # 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.data_type == :position
assert result.time == "092345/" assert result.time == "092345/"
@ -297,16 +297,16 @@ defmodule Parser.ParserTest do
test "handles various timestamp errors" do test "handles various timestamp errors" do
# Invalid hour (>23) # 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.data_type == :position
assert result.time =~ "252345" assert result.time =~ "252345"
# Invalid minute (>59) # 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 assert result.data_type == :position
# Invalid second (>59) # 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 assert result.data_type == :position
# Wrong format/length # Wrong format/length
@ -316,7 +316,7 @@ defmodule Parser.ParserTest do
test "handles MDHM timestamp format errors" do test "handles MDHM timestamp format errors" do
# Invalid MDHM format # 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 assert result.data_type == :position
end end
end end