add more dialyzer types
This commit is contained in:
parent
86548fbe19
commit
4b3d9a87ad
15 changed files with 227 additions and 21 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (/)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue