add aprs-is stuff

This commit is contained in:
Graham McIntire 2023-01-26 13:38:37 -06:00
parent d40567dfeb
commit 182f3e9cb2
15 changed files with 1031 additions and 11 deletions

2
.tool-versions Normal file
View file

@ -0,0 +1,2 @@
elixir 1.14.2-otp-25
erlang 25.2

View file

@ -59,6 +59,14 @@ config :logger, :console,
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
config :aprs,
ecto_repos: [Aprs.Repo],
aprs_is_server: System.get_env("APRS_SERVER", "dallas.aprs2.net"),
aprs_is_port: 14580,
aprs_is_default_filter: System.get_env("APRS_FILTER"),
aprs_is_login_id: System.get_env("APRS_CALLSIGN"),
aprs_is_password: System.get_env("APRS_PASSCODE")
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"

View file

@ -63,6 +63,14 @@ if config_env() == :prod do
],
secret_key_base: secret_key_base
config :aprs,
ecto_repos: [Aprs.Repo],
aprs_is_server: System.get_env("APRS_SERVER", "dallas.aprs2.net"),
aprs_is_port: 14580,
aprs_is_default_filter: System.get_env("APRS_FILTER"),
aprs_is_login_id: System.get_env("APRS_CALLSIGN"),
aprs_is_password: System.get_env("APRS_PASSCODE")
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key

View file

@ -7,6 +7,8 @@ defmodule Aprs.Application do
@impl true
def start(_type, _args) do
topologies = Application.get_env(:libcluster, :topologies) || []
children = [
# Start the Telemetry supervisor
AprsWeb.Telemetry,
@ -17,11 +19,21 @@ defmodule Aprs.Application do
# Start Finch
{Finch, name: Aprs.Finch},
# Start the Endpoint (http/https)
AprsWeb.Endpoint
AprsWeb.Endpoint,
# Start a worker by calling: Aprs.Worker.start_link(arg)
# {Aprs.Worker, arg}
{Registry, keys: :duplicate, name: Registry.PubSub, partitions: System.schedulers_online()},
{Cluster.Supervisor, [topologies, [name: Aprs.ClusterSupervisor]]},
Aprs.Presence
]
children =
if Application.get_env(:aprs, :env) in [:prod, :dev] do
children ++ [Aprs.Is.IsSupervisor]
else
children
end
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Aprs.Supervisor]

238
lib/aprs/is/is.ex Normal file
View file

@ -0,0 +1,238 @@
defmodule Aprs.Is do
use GenServer
require Logger
@aprs_timeout 30 * 1000
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(_opts) do
# Trap exits so we can gracefully shut down
Process.flag(:trap_exit, true)
# Get startup parameters
server = Application.get_env(:aprs, :aprs_is_server, 'rotate.aprs2.net')
port = Application.get_env(:aprs, :aprs_is_port, 14580)
default_filter = Application.get_env(:aprs, :aprs_is_default_filter, "r/47.6/-122.3/100")
aprs_user_id = Application.get_env(:aprs, :aprs_is_login_id, "w5isp")
aprs_passcode = Application.get_env(:aprs, :aprs_is_password, "-1")
# Set up ets tables
# with {:ok, :aprs} <- :ets.file2tab(:erlang.binary_to_list("priv/aprs.ets")),
# {:ok, :aprs_messages} <- :ets.file2tab(:erlang.binary_to_list("priv/aprs_messages.ets")) do
# else
# {:error, _reason} ->
# Logger.debug("Ets files not found. Creating new ETS tables.")
# # Create new ETS tables
# :ets.new(:aprs, [:set, :protected, :named_table])
# :ets.new(:aprs_messages, [:bag, :protected, :named_table])
# # Write them to file here in case genserver is brutally terminated
# :ets.tab2file(:aprs, :erlang.binary_to_list("priv/aprs.ets"))
# :ets.tab2file(:aprs_messages, :erlang.binary_to_list("priv/aprs_messages.ets"))
# _ ->
# Logger.error("Unable to set up ETS tables for message storage.")
# end
with {:ok, socket} <- connect_to_aprs_is(server, port),
:ok <- send_login_string(socket, aprs_user_id, aprs_passcode, default_filter),
timer <- create_timer(@aprs_timeout) do
{:ok,
%{
server: server,
port: port,
socket: socket,
timer: timer
}}
else
_ ->
Logger.error("Unable to establish connection or log in to APRS-IS")
{:stop, :aprs_connection_failed}
end
end
# Client API
def stop do
Logger.info("Stopping Server")
GenServer.stop(__MODULE__, :stop)
end
def set_filter(filter_string), do: send_message("#filter #{filter_string}")
def list_active_filters, do: send_message("#filter?")
def send_message(from, to, message) do
padded_callsign = String.pad_trailing(to, 9)
send_message("#{from}>APRS,TCPIP*::#{padded_callsign}:#{message}")
end
def send_message(message) do
GenServer.call(__MODULE__, {:send_message, message})
end
# Server methods
defp connect_to_aprs_is(server, port) do
Logger.debug("Connecting to #{server}:#{port}")
opts = [:binary, active: true]
:gen_tcp.connect(String.to_charlist(server), port, opts)
end
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} \n"
:gen_tcp.send(socket, login_string)
end
defp create_timer(timeout) do
Process.send_after(self(), :aprs_no_message_timeout, timeout)
end
@impl true
def handle_call({:send_message, message}, _from, state) do
next_ack_number = :ets.update_counter(:aprs, :message_number, 1)
# Append ack number
message = message <> "{" <> to_string(next_ack_number) <> "\r"
Logger.info("Sending message: #{inspect(message)}")
:gen_tcp.send(state.socket, message)
{:reply, :ok, state}
end
@impl true
def handle_info(:aprs_no_message_timeout, state) do
Logger.error("Socket timeout detected. Killing genserver.")
{:stop, :aprs_timeout, state}
end
def handle_info({:tcp, _socket, packet}, state) do
# Cancel the previous timer
Process.cancel_timer(state.timer)
# Handle the incoming message
# TODO: Spawn a process/genserver to handle the ETS functionality
# Task.start(Aprs, :dispatch, [packet])
if String.contains?(packet, "\n") or String.contains?(packet, "\r") do
packet
|> String.split("\r\n")
|> Enum.each(&dispatch(String.trim(&1)))
end
# dispatch(packet)
# Start a new timer
timer = Process.send_after(self(), :aprs_no_message_timeout, @aprs_timeout)
state = Map.put(state, :timer, timer)
{:noreply, state}
end
def handle_info({:tcp_closed, _socket}, state) do
Logger.info("Socket has been closed")
{:stop, :normal, state}
end
def handle_info({:tcp_error, _socket, reason}, state) do
Logger.error("Connection closed due to #{inspect(reason)}")
{:stop, :normal, state}
end
@impl true
def terminate(reason, state) do
# Do Shutdown Stuff
Logger.info("Going Down: #{inspect(reason)} - #{inspect(state)}")
# Logger.info("Attempting to write ets tables to disk.")
# :ets.tab2file(:aprs, :erlang.binary_to_list("priv/aprs.ets"))
# :ets.tab2file(:aprs_messages, :erlang.binary_to_list("priv/aprs_messages.ets"))
Logger.info("Closing socket")
:gen_tcp.close(state.socket)
:normal
end
@spec dispatch(binary) :: nil | :ok
def dispatch("#" <> _comment_text) do
# Logger.debug("COMMENT: " <> String.trim(comment_text))
end
def dispatch(""), do: nil
def dispatch(message) do
case Parser.parse(message) do
{:ok, parsed_message} ->
# time_message_received =
# :calendar.universal_time() |> :calendar.datetime_to_gregorian_seconds()
# This doesnt work if dispatch if spawned as a task, since it does not own the table
# :ets.insert(:aprs, {parsed_message.sender, message, time_message_received})
# Get messages since last time the callsign was seen
# TODO: Get last seen timestamp and use that
# _message_count =
# case :ets.lookup(:aprs_messages, parsed_message.sender) do
# [{_callsign, _message, timestamp}] ->
# recent_messages_for(parsed_message.sender, timestamp)
# [] ->
# 0
# _ ->
# 0
# end
# Logger.debug("#{message_count} recent messages for #{parsed_message.sender}")
# Do something interesting with the message
# process(parsed_message, parsed_message.data_type)
# Publish the parsed message to all interested parties
# Registry.dispatch(Registry.PubSub, "aprs_messages", fn entries ->
# for {pid, _} <- entries, do: send(pid, {:broadcast, parsed_message})
# end)
AprsWeb.Endpoint.broadcast("aprs_messages", "packet", parsed_message)
# Phoenix.PubSub.broadcast(
# Aprs.PubSub,
# "aprs_messages",
# {:packet, parsed_message}
# )
# IO.inspect(parsed_message)
# Logger.debug("SERVER:" <> message)
{:error, _error} ->
nil
# Logger.debug("PARSE ERROR: " <> error)
x ->
Logger.debug("PARSE ERROR: " <> x)
end
end
# def process(message, message_type) when message_type == :message do
# # time_message_received =
# # :calendar.universal_time() |> :calendar.datetime_to_gregorian_seconds()
# # :ets.insert(
# # :aprs_messages,
# # {message.data_extended.to, message.data_extended, time_message_received}
# # )
# end
# def process(_, _), do: nil
# def recent_messages_for(callsign, since_time) do
# callsign_guard = {:==, :"$1", {:const, callsign}}
# timestamp_guard = {:>=, :"$2", {:const, since_time}}
# total_spec = [{{:"$1", :_, :"$2"}, [{:andalso, callsign_guard, timestamp_guard}], [true]}]
# :ets.select_count(:aprs_messages, total_spec)
# end
end

View file

@ -0,0 +1,16 @@
defmodule Aprs.Is.IsSupervisor do
use Supervisor
def start_link(opts) do
Supervisor.start_link(__MODULE__, :ok, opts)
end
@impl true
def init(:ok) do
children = [
Aprs.Is
]
Supervisor.init(children, strategy: :one_for_one)
end
end

7
lib/aprs/packets.ex Normal file
View file

@ -0,0 +1,7 @@
defmodule Aprs.Packets do
@moduledoc """
The Packets context.
"""
import Ecto.Query, warn: false
end

3
lib/aprs/presence.ex Normal file
View file

@ -0,0 +1,3 @@
defmodule Aprs.Presence do
use Phoenix.Presence, otp_app: :aprs, pubsub_server: Aprs.PubSub
end

28
lib/aprs/release.ex Normal file
View file

@ -0,0 +1,28 @@
defmodule Aprs.Release do
@moduledoc """
Used for executing DB release tasks when run in production without Mix
installed.
"""
@app :aprs
def migrate do
load_app()
for repo <- repos() do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end
end
def rollback(repo, version) do
load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
defp repos do
Application.fetch_env!(@app, :ecto_repos)
end
defp load_app do
Application.load(@app)
end
end

516
lib/parser.ex Normal file
View file

@ -0,0 +1,516 @@
defmodule Parser do
@moduledoc """
Main parsing library
"""
use Bitwise
alias Parser.Types.{MicE, Position}
require Logger
def parse(message) do
try do
[sender, path, data] = String.split(message, [">", ":"], parts: 3)
with [base_callsign, ssid] <- parse_callsign(sender),
data_type <- parse_datatype(String.first(data)),
data <- String.trim(data),
[destination, path] <- String.split(path, ",", parts: 2),
data_extended <- parse_data(data_type, destination, data) do
{:ok,
%{
sender: sender,
path: path,
destination: destination,
information_field: data,
data_type: data_type,
base_callsign: base_callsign,
ssid: ssid,
data_extended: data_extended
}}
else
true ->
{:error, "PARSE ERROR"}
end
rescue
_ ->
# Logger.debug("PARSE ERROR: " <> message)
# {:ok, file} = File.open("/home/graham/badpackets.txt", [:append])
# IO.binwrite(file, message <> "\n\n")
# File.close(file)
{:error, "PARSE ERROR"}
end
end
def parse_callsign(callsign) do
if String.contains?(callsign, "-") do
String.split(callsign, "-")
else
[callsign, nil]
end
end
# One of the nutty exceptions in the APRS protocol has to do with this
# data type indicator. It's usually the first character of the message.
# However, in some rare cases, the ! indicator can be anywhere in the
# 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.
def parse_datatype(datatype) when datatype == ":", do: :message
def parse_datatype(datatype) when datatype == ">", do: :status
def parse_datatype(datatype) when datatype == "!", do: :position
def parse_datatype(datatype) when datatype == "/", do: :timestamped_position
def parse_datatype(datatype) when datatype == "=", do: :position_with_message
def parse_datatype(datatype) when datatype == "@", do: :timestamped_position_with_message
def parse_datatype(datatype) when datatype == ";", do: :object
def parse_datatype(datatype) when datatype == "`", do: :mic_e
def parse_datatype(datatype) when datatype == "'", do: :mic_e_old
def parse_datatype(datatype) when datatype == "_", do: :weather
def parse_datatype(datatype) when datatype == "T", do: :telemetry
def parse_datatype(datatype) when datatype == "$", do: :raw_gps_ultimeter
def parse_datatype(datatype) when datatype == "<", do: :station_capabilities
def parse_datatype(datatype) when datatype == "?", do: :query
def parse_datatype(datatype) when datatype == "{", do: :user_defined
def parse_datatype(datatype) when datatype == "}", do: :third_party_traffic
def parse_datatype(_datatype), do: :unknown_datatype
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(:position, _destination, data), do: parse_position_without_timestamp(false, data)
def parse_data(:position_with_message, _destination, data),
do: parse_position_without_timestamp(true, data)
def parse_data(:timestamped_position, _destination, data),
do: parse_position_with_timestamp(false, data)
def parse_data(
:timestamped_position_with_message,
_destination,
<<_dti::binary-size(1), date_time_position::binary-size(25), "_", weather_report::binary>>
) do
parse_position_with_datetime_and_weather(true, date_time_position, weather_report)
end
def parse_data(:timestamped_position_with_message, _destination, data),
do: parse_position_with_timestamp(true, data)
def parse_data(
:message,
_destination,
<<":", addressee::binary-size(9), ":", message_text::binary>>
) do
# Aprs messages can have an optional message number tacked onto the end
# for the purposes of acknowledging message receipt.
# The sender tacks the message number onto the end of the message,
# and the receiving station is supposed to respond back with an
# acknowledgement of that message number.
# Example
# Sender: Hello world{123
# Receiver: ack123
# Special thanks to Jeff Smith(https://github.com/electricshaman) for the regex
regex = ~r/^(?<message>.*?)(?:{(?<message_number>\w+))?$/i
result = find_matches(regex, message_text)
message_text =
case result["message"] do
nil -> ""
m -> String.trim(m)
end
%{
to: String.trim(addressee),
message_text: message_text,
message_number: result["message_number"]
}
end
def parse_data(_type, _destination, _data), do: nil
def parse_position_with_datetime_and_weather(
aprs_messaging?,
date_time_position_data,
weather_report
) do
<<time::binary-size(7), latitude::binary-size(8), sym_table_id::binary-size(1),
longitude::binary-size(9)>> = date_time_position_data
# position = Parser.Types.Position.from_aprs(latitude, longitude)
%{latitude: lat, longitude: lon} = Parser.Types.Position.from_aprs(latitude, longitude)
%{
latitude: lat,
longitude: lon,
timestamp: time,
symbol_table_id: sym_table_id,
symbol_code: "_",
weather: weather_report,
data_type: :position_with_datetime_and_weather,
aprs_messaging?: aprs_messaging?
}
end
def decode_compressed_position(
<<"/", latitude::binary-size(4), longitude::binary-size(4), _symbol::binary-size(1),
_cs::binary-size(2), _compression_type::binary-size(2), _rest::binary>>
) do
lat = convert_to_base91(latitude)
lon = convert_to_base91(longitude)
[:ok, lat, lon]
end
defp 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
def parse_position_without_timestamp(_aprs_messaging?, <<"!!", rest::binary>> = message) do
# this is an ultimeter weather station. need to parse its weird format
# {:ok, file} = File.open("badpackets.txt")
# IO.puts(file, message)
# File.close(file)
# a = "0000000001FF000427C70002CCD30001026E003A050F00040000"
[
wind_speed,
wind_direction,
temp,
rain_long_term_total,
barrometer,
barrometer_delta_value,
barrometer_corr_factor_lsw,
barrometer_corr_factor_msw,
humidity,
_day_of_year,
_minute_of_day,
today_rain_total,
wind_speed_avg
] =
rest
|> String.codepoints()
|> Enum.chunk_every(4)
|> Enum.map(&Enum.join/1)
|> Enum.map(&hex_decode(&1))
%{
wind_speed: convert_ultimeter_wind(wind_speed),
wind_direction: wind_direction,
temp_f: convert_ultimeter_temp(temp),
rain_long_term_total: rain_long_term_total,
barrometer: barrometer,
barrometer_delta_value: barrometer_delta_value,
barrometer_corr_factor_lsw: barrometer_corr_factor_lsw,
barrometer_corr_factor_msw: barrometer_corr_factor_msw,
humidity: humidity,
today_rain_total: today_rain_total,
wind_speed_avg: wind_speed_avg
}
Logger.debug("TODO: PARSE ULTIMETER DATA: " <> message)
end
def parse_position_without_timestamp(
aprs_messaging?,
<<_dti::binary-size(1), latitude::binary-size(8), sym_table_id::binary-size(1),
longitude::binary-size(9), symbol_code::binary-size(1), comment::binary>>
) do
try do
# position = Position.from_aprs(latitude, longitude)
%{latitude: lat, longitude: lon} = Parser.Types.Position.from_aprs(latitude, longitude)
%{
latitude: lat,
longitude: lon,
symbol_table_id: sym_table_id,
symbol_code: symbol_code,
comment: comment,
data_type: :position,
aprs_messaging?: aprs_messaging?
}
rescue
e -> Logger.error(Exception.format(:error, e, __STACKTRACE__))
end
end
def parse_position_without_timestamp(
_aprs_messaging?,
<<_dti::binary-size(1), "/", _latitude::binary-size(4), _longitude::binary-size(4),
_sym_table_id::binary-size(1), _cs::binary-size(2), _compression_type::binary-size(1),
_comment::binary>> = message
) do
# {:ok, file} = File.open("badpackets.txt")
# IO.puts(file, message)
# File.close(file)
Logger.debug("TODO: PARSE COMPRESSED LAT/LON: " <> message)
end
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>>
) do
try do
position = Position.from_aprs(latitude, longitude)
%{latitude: lat, longitude: lon} = Parser.Types.Position.from_aprs(latitude, longitude)
%{
latitude: lat,
longitude: lon,
position: position,
time: time,
symbol_table_id: sym_table_id,
symbol_code: symbol_code,
comment: comment,
data_type: :position,
aprs_messaging?: aprs_messaging?
}
rescue
e -> Logger.error(Exception.format(:error, e, __STACKTRACE__))
end
end
def parse_mic_e(destination_field, information_field) do
# Mic-E is kind of a nutty compression scheme, APRS packs additional
# information into the destination field when Mic-E encoding is used.
# No other aprs packets use the destination field this way as far as i know.
# The destination field contains the following information:
# Latitude, message code, N/S & E/W indicators, longitude offset, digipath code
destination_data = parse_mic_e_destination(destination_field)
information_data =
parse_mic_e_information(information_field, destination_data.longitude_offset)
%MicE{
lat_degrees: destination_data.lat_degrees,
lat_minutes: destination_data.lat_minutes,
lat_fractional: destination_data.lat_fractional,
lat_direction: destination_data.lat_direction,
lon_direction: destination_data.lon_direction,
longitude_offset: destination_data.longitude_offset,
message_code: destination_data.message_code,
message_description: destination_data.message_description,
dti: information_data.dti,
heading: information_data.heading,
lon_degrees: information_data.lon_degrees,
lon_minutes: information_data.lon_minutes,
lon_fractional: information_data.lon_fractional,
speed: information_data.speed,
manufacturer: information_data.manufacturer,
message: information_data.message
}
end
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]
def parse_mic_e_digit("K"), do: [0, 1, :custom]
def parse_mic_e_digit("L"), do: [0, 0, nil]
def parse_mic_e_digit("Z"), do: [0, 1, :standard]
def parse_mic_e_digit(_c), do: [:unknown, :unknown, :unknown]
def parse_mic_e_destination(destination_field) do
digits =
destination_field
|> String.codepoints()
|> Enum.map(&parse_mic_e_digit/1)
|> Enum.map(&hd/1)
deg = digits |> Enum.slice(0..1) |> Enum.join() |> String.to_integer()
min = digits |> Enum.slice(2..3) |> Enum.join() |> String.to_integer()
fractional = digits |> Enum.slice(4..5) |> Enum.join() |> String.to_integer()
[ns, lo, ew] = destination_field |> to_charlist |> Enum.slice(3..5)
north_south_indicator =
case ns do
x when x in ?0..?9 -> :south
x when x == ?L -> :south
x when x in ?P..?Z -> :north
_ -> :unknown
end
east_west_indicator =
case ew do
x when x in ?0..?9 -> :east
x when x == ?L -> :east
x when x in ?P..?Z -> :west
_ -> :unknown
end
longitude_offset =
case lo do
x when x in ?0..?9 -> 0
x when x == ?L -> 0
x when x in ?P..?Z -> 100
_ -> :unknown
end
statuses = [
"Emergency",
"Priority",
"Special",
"Committed",
"Returning",
"In Service",
"En Route",
"Off Duty"
]
message_digits =
destination_field
|> String.codepoints()
|> Enum.take(3)
[_, message_bit_1, message_type] = parse_mic_e_digit(Enum.at(message_digits, 0))
[_, message_bit_2, _] = parse_mic_e_digit(Enum.at(message_digits, 1))
[_, message_bit_3, _] = parse_mic_e_digit(Enum.at(message_digits, 2))
# Convert the bits to binary to get the array index
index = message_bit_1 * 4 + message_bit_2 * 2 + message_bit_3
# need to invert this from the actual array index
display_index = to_string(7 - index) |> String.pad_leading(2, "0")
[message_code, message_description] =
case message_type do
:standard ->
["M" <> display_index, Enum.at(statuses, index)]
:custom ->
["C" <> display_index, "Custom-#{display_index}"]
nil ->
["", Enum.at(statuses, index)]
end
%{
lat_degrees: deg,
lat_minutes: min,
lat_fractional: fractional,
lat_direction: north_south_indicator,
lon_direction: east_west_indicator,
longitude_offset: longitude_offset,
message_code: message_code,
message_description: message_description
}
end
def parse_mic_e_information(
<<dti::binary-size(1), d28::integer, m28::integer, f28::integer, sp28::integer,
dc28::integer, se28::integer, symbol::binary-size(1), table::binary-size(1),
message::binary>> = _information_field,
longitude_offset
) do
m =
case m28 - 28 do
x when x >= 60 -> x - 60
x -> x
end
sp =
case sp28 - 28 do
x when x >= 80 -> x - 80
x -> x
end
dc = dc28 - 28
quotient = div(dc, 10)
remainder = rem(dc, 10)
dc = sp * 10 + quotient
heading = (remainder - 4) * 100 + (se28 - 28)
# Messages should at least have a starting and ending symbol, and an optional message in between
# But, there might not be any symbols either, so it could look like any of the following:
# >^ <- TH-D74
# nil <- who knows
# ]\"55}146.820 MHz T103 -0600= <- Kenwood DM-710
regex = ~r/^(?<first>.?)(?<msg>.*)(?<secondtolast>.)(?<last>.)$/i
result = find_matches(regex, message)
symbol1 =
if result["first"] == "" do
result["secondtolast"]
else
result["first"]
end
manufacturer = parse_manufacturer(symbol1, result["secondtolast"], result["last"])
%{
dti: dti,
lon_degrees: d28 - 28 + longitude_offset,
lon_minutes: m,
lon_fractional: f28 - 28,
speed: dc,
heading: heading,
symbol: symbol,
table: table,
manufacturer: manufacturer,
message: message
}
end
def parse_manufacturer(" ", _s2, _s3), do: "Original MIC-E"
def parse_manufacturer(">", _s2, "="), do: "Kenwood TH-D72"
def parse_manufacturer(">", _s2, "^"), do: "Kenwood TH-D74"
def parse_manufacturer(">", _s2, _s3), do: "Kenwood TH-D74A"
def parse_manufacturer("]", _s2, "="), do: "Kenwood DM-710"
def parse_manufacturer("]", _s2, _s3), do: "Kenwood DM-700"
def parse_manufacturer("`", "_", " "), do: "Yaesu VX-8"
def parse_manufacturer("`", "_", "\""), do: "Yaesu FTM-350"
def parse_manufacturer("`", "_", "#"), do: "Yaesu VX-8G"
def parse_manufacturer("`", "_", "$"), do: "Yaesu FT1D"
def parse_manufacturer("`", "_", "%"), do: "Yaesu FTM-400DR"
def parse_manufacturer("`", "_", ")"), do: "Yaesu FTM-100D"
def parse_manufacturer("`", "_", "("), do: "Yaesu FT2D"
def parse_manufacturer("`", " ", "X"), do: "AP510"
def parse_manufacturer("`", _s2, _s3), do: "Mic-Emsg"
def parse_manufacturer("'", "|", "3"), do: "Byonics TinyTrack3"
def parse_manufacturer("'", "|", "4"), do: "Byonics TinyTrack4"
def parse_manufacturer("'", ":", "4"), do: "SCS GmbH & Co. P4dragon DR-7400 modems"
def parse_manufacturer("'", ":", "8"), do: "SCS GmbH & Co. P4dragon DR-7800 modems"
def parse_manufacturer("'", _s2, _s3), do: "McTrackr"
def parse_manufacturer(_s1, "\"", _s3), do: "Hamhud ?"
def parse_manufacturer(_s1, "/", _s3), do: "Argent ?"
def parse_manufacturer(_s1, "^", _s3), do: "HinzTec anyfrog"
def parse_manufacturer(_s1, "*", _s3), do: "APOZxx www.KissOZ.dk Tracker. OZ1EKD and OZ7HVO"
def parse_manufacturer(_s1, "~", _s3), do: "Other"
def parse_manufacturer(_symbol1, _symbol2, _symbol3), do: :unknown_manufacturer
defp find_matches(regex, text) do
case Regex.names(regex) do
[] ->
matches = Regex.run(regex, text)
Enum.reduce(Enum.with_index(matches), %{}, fn {match, index}, acc ->
Map.put(acc, index, match)
end)
_ ->
Regex.named_captures(regex, text)
end
end
defp hex_decode(input) do
{result, ""} = Integer.parse(input, 16)
result
end
# defp convert_ultimeter_humidity(hum) do
# hum * 10
# end
defp convert_ultimeter_wind(wind) do
# convert to mph
wind * 0.0621371192
end
defp convert_ultimeter_temp(temp) do
temp / 10
end
end

View file

@ -0,0 +1,11 @@
defimpl Inspect, for: Parser.Types.Position do
alias Parser.Types.Position
def inspect(d, %{:structs => false} = opts) do
Inspect.Algebra.to_doc(d, opts)
end
def inspect(d, _opts) do
"#{Position.to_string(d)}"
end
end

21
lib/types/mic_e.ex Normal file
View file

@ -0,0 +1,21 @@
defmodule Parser.Types.MicE do
@moduledoc """
Type struct for MicE
"""
defstruct lat_degrees: 0,
lat_minutes: 0,
lat_fractional: 0,
lat_direction: :unknown,
lon_direction: :unknown,
longitude_offset: 0,
message_code: nil,
message_description: nil,
dti: nil,
heading: 0,
lon_degrees: 0,
lon_minutes: 0,
lon_fractional: 0,
speed: 0,
manufacturer: :unknown,
message: ""
end

112
lib/types/position.ex Normal file
View file

@ -0,0 +1,112 @@
defmodule Parser.Types.Position do
@moduledoc """
Positition Decoder
"""
alias __MODULE__
require Logger
defstruct lat_degrees: 0,
lat_minutes: 0,
lat_fractional: 0,
lat_direction: :unknown,
lon_direction: :unknown,
lon_degrees: 0,
lon_minutes: 0,
lon_fractional: 0
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")
# Logger.debug("#{aprs_latitude} #{aprs_longitude}")
<<latitude::binary-size(8), lat_direction::binary>> = aprs_latitude
<<longitude::binary-size(8), lon_direction::binary>> = aprs_longitude
<<lat_deg::binary-size(3), lat_min::binary-size(2), lat_fractional::binary-size(3)>> =
convert_garbage_to_zero(latitude)
<<lon_deg::binary-size(3), lon_min::binary-size(2), lon_fractional::binary-size(3)>> =
convert_garbage_to_zero(longitude)
# %Position{
# lat_degrees: lat_deg |> String.replace(".", "") |> String.to_integer(),
# lat_minutes: lat_min |> String.replace(".", "") |> String.to_integer(),
# lat_fractional: convert_fractional(lat_fractional),
# lat_direction: convert_direction(lat_direction),
# lon_degrees: lon_deg |> String.replace(".", "") |> String.to_integer(),
# lon_minutes: lon_min |> String.replace(".", "") |> String.to_integer(),
# lon_fractional: convert_fractional(lon_fractional),
# lon_direction: convert_direction(lon_direction)
# }
# |> IO.
try do
lat =
Geocalc.DMS.to_decimal(%Geocalc.DMS{
hours: String.to_integer(lat_deg),
minutes: String.to_integer(lat_min),
seconds: convert_fractional(lat_fractional),
direction: lat_direction
})
long =
Geocalc.DMS.to_decimal(%Geocalc.DMS{
hours: String.to_integer(lon_deg),
minutes: String.to_integer(lon_min),
seconds: convert_fractional(lon_fractional),
direction: lon_direction
})
%{latitude: lat, longitude: long}
rescue
_ -> %{latitude: 0, longitude: 0}
end
end
defp convert_garbage_to_zero(value) do
try do
_ = String.to_float(value)
value
rescue
ArgumentError -> "00000.00"
end
end
# def to_string(%__MODULE__{} = position) do
# "#{position.lat_degrees}°" <>
# "#{position.lat_minutes}'" <>
# "#{position.lat_fractional}\"" <>
# "#{convert_direction(position.lat_direction)} " <>
# "#{position.lon_degrees}°" <>
# "#{position.lon_minutes}'" <>
# "#{position.lon_fractional}\"" <> "#{convert_direction(position.lon_direction)}"
# end
defp convert_direction("N"), do: :north
defp convert_direction("S"), do: :south
defp convert_direction("E"), do: :east
defp convert_direction("W"), do: :west
defp convert_direction(:north), do: "N"
defp convert_direction(:south), do: "S"
defp convert_direction(:east), do: "E"
defp convert_direction(:west), do: "W"
defp convert_direction(:unknown), do: ""
defp convert_direction(_nomatch), do: ""
# defp convert_fractional(fractional),
# do:
# fractional
# |> String.trim()
# |> String.pad_leading(4, "0")
# |> String.to_float()
# |> Kernel.*(60)
# |> Float.round(2)
defp convert_fractional(fractional),
do:
fractional
|> String.trim()
|> String.pad_leading(4, "0")
|> String.to_float()
|> Kernel.*(60)
end

36
mix.exs
View file

@ -9,7 +9,8 @@ defmodule Aprs.MixProject do
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps()
deps: deps(),
releases: releases()
]
end
@ -32,25 +33,32 @@ defmodule Aprs.MixProject do
# Type `mix help deps` for examples and options.
defp deps do
[
{:certifi, "~> 2.9"},
{:ecto_sql, "~> 3.6"},
{:finch, "~> 0.13"},
{:geocalc, "~> 0.8"},
{:heroicons, "~> 0.5"},
{:jason, "~> 1.2"},
{:libcluster, "~> 3.3"},
{:oban, "~> 2.11"},
{:phoenix, "~> 1.7.0-rc.0", override: true},
{:phoenix_ecto, "~> 4.4"},
{:ecto_sql, "~> 3.6"},
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 3.0"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 0.18.3"},
{:heroicons, "~> 0.5"},
{:floki, ">= 0.30.0", only: :test},
{:phoenix_live_dashboard, "~> 0.7.2"},
{:esbuild, "~> 0.5", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.1.8", runtime: Mix.env() == :dev},
{:swoosh, "~> 1.3"},
{:finch, "~> 0.13"},
{:timex, "~> 3.4"},
{:telemetry_metrics, "~> 0.6"},
{:telemetry_poller, "~> 1.0"},
{:gettext, "~> 0.20"},
{:jason, "~> 1.2"},
{:plug_cowboy, "~> 2.5"}
{:plug_cowboy, "~> 2.5"},
{:esbuild, "~> 0.5", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.1.8", runtime: Mix.env() == :dev},
{:credo, "~> 1.6.2", only: [:dev, :test], runtime: false},
{:floki, ">= 0.30.0", only: :test},
{:exvcr, "~> 0.13.4", only: [:test]},
{:mix_test_watch, "~> 1.1", only: [:dev, :test]}
]
end
@ -69,4 +77,12 @@ defmodule Aprs.MixProject do
"assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"]
]
end
defp releases do
[
aprs: [
cookie: "aprs"
]
]
end
end

View file

@ -1,26 +1,44 @@
%{
"bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"},
"castore": {:hex, :castore, "0.1.22", "4127549e411bedd012ca3a308dede574f43819fe9394254ca55ab4895abfa1a2", [:mix], [], "hexpm", "c17576df47eb5aa1ee40cc4134316a99f5cad3e215d5c77b8dd3cfef12a22cac"},
"certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
"connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
"credo": {:hex, :credo, "1.6.7", "323f5734350fd23a456f2688b9430e7d517afb313fbd38671b8a4449798a7854", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "41e110bfb007f7eda7f897c10bf019ceab9a0b269ce79f015d54b0dcf4fc7dd3"},
"db_connection": {:hex, :db_connection, "2.4.3", "3b9aac9f27347ec65b271847e6baeb4443d8474289bd18c1d6f4de655b70c94d", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c127c15b0fa6cfb32eed07465e05da6c815b032508d4ed7c116122871df73c12"},
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
"ecto": {:hex, :ecto, "3.9.4", "3ee68e25dbe0c36f980f1ba5dd41ee0d3eb0873bccae8aeaf1a2647242bffa35", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "de5f988c142a3aa4ec18b85a4ec34a2390b65b24f02385c1144252ff6ff8ee75"},
"ecto_sql": {:hex, :ecto_sql, "3.9.2", "34227501abe92dba10d9c3495ab6770e75e79b836d114c41108a4bf2ce200ad5", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1eb5eeb4358fdbcd42eac11c1fbd87e3affd7904e639d77903c1358b2abd3f70"},
"esbuild": {:hex, :esbuild, "0.6.0", "9ba6ead054abd43cb3d7b14946a0cdd1493698ccd8e054e0e5d6286d7f0f509c", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "30f9a05d4a5bab0d3e37398f312f80864e1ee1a081ca09149d06d474318fd040"},
"exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm", "1222419f706e01bfa1095aec9acf6421367dcfab798a6f67c54cf784733cd6b5"},
"exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm", "32e95820a97cffea67830e91514a2ad53b888850442d6d395f53a1ac60c82e07"},
"expo": {:hex, :expo, "0.3.0", "13127c1d5f653b2927f2616a4c9ace5ae372efd67c7c2693b87fd0fdc30c6feb", [:mix], [], "hexpm", "fb3cd4bf012a77bc1608915497dae2ff684a06f0fa633c7afa90c4d72b881823"},
"exvcr": {:hex, :exvcr, "0.13.4", "68efca5ae04a909b29a9e137338a7033642898033c7a938a5faec545bfc5a38e", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "42920a59bdeef34001f8c2305a57d68b29d8a2e7aa1877bb35a75034b9f9904a"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"finch": {:hex, :finch, "0.14.0", "619bfdee18fc135190bf590356c4bf5d5f71f916adb12aec94caa3fa9267a4bc", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5459acaf18c4fdb47a8c22fb3baff5d8173106217c8e56c5ba0b93e66501a8dd"},
"floki": {:hex, :floki, "0.34.0", "002d0cc194b48794d74711731db004fafeb328fe676976f160685262d43706a8", [:mix], [], "hexpm", "9c3a9f43f40dde00332a589bd9d389b90c1f518aef500364d00636acc5ebc99c"},
"geocalc": {:hex, :geocalc, "0.8.5", "b9886679e44c323e5b72dcd90a64f834d775d2600af0b656ea9f07ccdacaa5a6", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "3870c25c78513ec0456b69324c2be1af2202961002e81fb659559e3db162c802"},
"gettext": {:hex, :gettext, "0.22.0", "a25d71ec21b1848957d9207b81fd61cb25161688d282d58bdafef74c2270bdc4", [:mix], [{:expo, "~> 0.3.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "cb0675141576f73720c8e49b4f0fd3f2c69f0cd8c218202724d4aebab8c70ace"},
"hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~> 2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"},
"heroicons": {:hex, :heroicons, "0.5.2", "a7ae72460ecc4b74a4ba9e72f0b5ac3c6897ad08968258597da11c2b0b210683", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "7ef96f455c1c136c335f1da0f1d7b12c34002c80a224ad96fc0ebf841a6ffef5"},
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"},
"jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm", "fc3499fed7a726995aa659143a248534adc754ebd16ccd437cd93b649a95091f"},
"libcluster": {:hex, :libcluster, "3.3.2", "84c6ebfdc72a03805955abfb5ff573f71921a3e299279cc3445445d5af619ad1", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8b691ce8185670fc8f3fc0b7ed59eff66c6889df890d13411f8f1a0e6871d8a5"},
"meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"mint": {:hex, :mint, "1.4.2", "50330223429a6e1260b2ca5415f69b0ab086141bc76dc2fbf34d7c389a6675b2", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "ce75a5bbcc59b4d7d8d70f8b2fc284b1751ffb35c7b6a6302b5192f8ab4ddd80"},
"mix_test_watch": {:hex, :mix_test_watch, "1.1.0", "330bb91c8ed271fe408c42d07e0773340a7938d8a0d281d57a14243eae9dc8c3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "52b6b1c476cbb70fd899ca5394506482f12e5f6b0d6acff9df95c7f1e0812ec3"},
"nimble_options": {:hex, :nimble_options, "0.5.2", "42703307b924880f8c08d97719da7472673391905f528259915782bb346e0a1b", [:mix], [], "hexpm", "4da7f904b915fd71db549bcdc25f8d56f378ef7ae07dc1d372cbe72ba950dce0"},
"nimble_pool": {:hex, :nimble_pool, "0.2.6", "91f2f4c357da4c4a0a548286c84a3a28004f68f05609b4534526871a22053cde", [:mix], [], "hexpm", "1c715055095d3f2705c4e236c18b618420a35490da94149ff8b580a2144f653f"},
"oban": {:hex, :oban, "2.14.0", "a40e74e2953abeba866df03301685055ada3eff0f8c2e9828e2dd8c656116f74", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ecd1344abc51c93cab10ca8c3169213a6020b6851e3e59b5bf509041a2433eaa"},
"parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
"phoenix": {:hex, :phoenix, "1.7.0-rc.2", "8faaff6f699aad2fe6a003c627da65d0864c868a4c10973ff90abfd7286c1f27", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.4", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "71abde2f67330c55b625dcc0e42bf76662dbadc7553c4f545c2f3759f40f7487"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"},
"phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"},
@ -34,11 +52,15 @@
"plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"},
"postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"swoosh": {:hex, :swoosh, "1.9.1", "0a5d7bf9954eb41d7e55525bc0940379982b090abbaef67cd8e1fd2ed7f8ca1a", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "76dffff3ffcab80f249d5937a592eaef7cc49ac6f4cdd27e622868326ed6371e"},
"tailwind": {:hex, :tailwind, "0.1.9", "25ba09d42f7bfabe170eb67683a76d6ec2061952dc9bd263a52a99ba3d24bd4d", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "9213f87709c458aaec313bb5f2df2b4d2cedc2b630e4ae821bf3c54c47a56d0b"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"},
"telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"},
"timex": {:hex, :timex, "3.7.9", "790cdfc4acfce434e442f98c02ea6d84d0239073bfd668968f82ac63e9a6788d", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "64691582e5bb87130f721fc709acfb70f24405833998fabf35be968984860ce1"},
"tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
"websock": {:hex, :websock, "0.4.3", "184ac396bdcd3dfceb5b74c17d221af659dd559a95b1b92041ecb51c9b728093", [:mix], [], "hexpm", "5e4dd85f305f43fd3d3e25d70bec4a45228dfed60f0f3b072d8eddff335539cf"},
"websock_adapter": {:hex, :websock_adapter, "0.4.5", "30038a3715067f51a9580562c05a3a8d501126030336ffc6edb53bf57d6d2d26", [:mix], [{:bandit, "~> 0.6", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.4", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "1d9812dc7e703c205049426fd4fe0852a247a825f91b099e53dc96f68bafe4c8"},
}