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
@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)

View file

@ -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

View file

@ -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, [

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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))

View file

@ -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)

View file

@ -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 =

View file

@ -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")

View file

@ -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 (/)

View file

@ -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

View file

@ -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
<<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
@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(<<value::binary-size(4)>>) 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
<<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
@spec parse_position_with_timestamp(boolean(), binary(), atom()) :: map()
def parse_position_with_timestamp(
aprs_messaging?,
<<_dti::binary-size(1), time::binary-size(7), latitude::binary-size(8), sym_table_id::binary-size(1),
longitude::binary-size(9), symbol_code::binary-size(1), comment::binary>>
<<time::binary-size(7), latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9),
symbol_code::binary-size(1), comment::binary>>,
_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
<<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
@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 ?A..?J, do: [c - ?A, 1, :custom]
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]
@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(<<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
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

View file

@ -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

View file

@ -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