From 182f3e9cb241fd59a1832a11de87ed47e506e90f Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 26 Jan 2023 13:38:37 -0600 Subject: [PATCH] add aprs-is stuff --- .tool-versions | 2 + config/config.exs | 8 + config/runtime.exs | 8 + lib/aprs/application.ex | 14 +- lib/aprs/is/is.ex | 238 ++++++++++++++++ lib/aprs/is/is_supervisor.ex | 16 ++ lib/aprs/packets.ex | 7 + lib/aprs/presence.ex | 3 + lib/aprs/release.ex | 28 ++ lib/parser.ex | 516 ++++++++++++++++++++++++++++++++++ lib/types/inspect_position.ex | 11 + lib/types/mic_e.ex | 21 ++ lib/types/position.ex | 112 ++++++++ mix.exs | 36 ++- mix.lock | 22 ++ 15 files changed, 1031 insertions(+), 11 deletions(-) create mode 100644 .tool-versions create mode 100644 lib/aprs/is/is.ex create mode 100644 lib/aprs/is/is_supervisor.ex create mode 100644 lib/aprs/packets.ex create mode 100644 lib/aprs/presence.ex create mode 100644 lib/aprs/release.ex create mode 100644 lib/parser.ex create mode 100644 lib/types/inspect_position.ex create mode 100644 lib/types/mic_e.ex create mode 100644 lib/types/position.ex diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..fee7f1f --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +elixir 1.14.2-otp-25 +erlang 25.2 diff --git a/config/config.exs b/config/config.exs index 57f610b..15c1360 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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" diff --git a/config/runtime.exs b/config/runtime.exs index 57d1c65..2cdfa3c 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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 diff --git a/lib/aprs/application.ex b/lib/aprs/application.ex index e92cf58..e6fca08 100644 --- a/lib/aprs/application.ex +++ b/lib/aprs/application.ex @@ -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] diff --git a/lib/aprs/is/is.ex b/lib/aprs/is/is.ex new file mode 100644 index 0000000..e63c120 --- /dev/null +++ b/lib/aprs/is/is.ex @@ -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 diff --git a/lib/aprs/is/is_supervisor.ex b/lib/aprs/is/is_supervisor.ex new file mode 100644 index 0000000..60c7a7c --- /dev/null +++ b/lib/aprs/is/is_supervisor.ex @@ -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 diff --git a/lib/aprs/packets.ex b/lib/aprs/packets.ex new file mode 100644 index 0000000..cd6aee0 --- /dev/null +++ b/lib/aprs/packets.ex @@ -0,0 +1,7 @@ +defmodule Aprs.Packets do + @moduledoc """ + The Packets context. + """ + + import Ecto.Query, warn: false +end diff --git a/lib/aprs/presence.ex b/lib/aprs/presence.ex new file mode 100644 index 0000000..4a21696 --- /dev/null +++ b/lib/aprs/presence.ex @@ -0,0 +1,3 @@ +defmodule Aprs.Presence do + use Phoenix.Presence, otp_app: :aprs, pubsub_server: Aprs.PubSub +end diff --git a/lib/aprs/release.ex b/lib/aprs/release.ex new file mode 100644 index 0000000..83db4c5 --- /dev/null +++ b/lib/aprs/release.ex @@ -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 diff --git a/lib/parser.ex b/lib/parser.ex new file mode 100644 index 0000000..4757f83 --- /dev/null +++ b/lib/parser.ex @@ -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/^(?.*?)(?:{(?\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 + <> = 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(<>) 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(<>) when c in ?0..?9, do: [c - ?0, 0, nil] + def parse_mic_e_digit(<>) when c in ?A..?J, do: [c - ?A, 1, :custom] + def parse_mic_e_digit(<>) when c in ?P..?Y, do: [c - ?P, 1, :standard] + + 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( + <> = _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/^(?.?)(?.*)(?.)(?.)$/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 diff --git a/lib/types/inspect_position.ex b/lib/types/inspect_position.ex new file mode 100644 index 0000000..e81f4a7 --- /dev/null +++ b/lib/types/inspect_position.ex @@ -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 diff --git a/lib/types/mic_e.ex b/lib/types/mic_e.ex new file mode 100644 index 0000000..5b1946a --- /dev/null +++ b/lib/types/mic_e.ex @@ -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 diff --git a/lib/types/position.ex b/lib/types/position.ex new file mode 100644 index 0000000..a05d3aa --- /dev/null +++ b/lib/types/position.ex @@ -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}") + + <> = aprs_latitude + <> = aprs_longitude + + <> = + convert_garbage_to_zero(latitude) + + <> = + 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 diff --git a/mix.exs b/mix.exs index 33d7ba0..9e276cc 100644 --- a/mix.exs +++ b/mix.exs @@ -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 diff --git a/mix.lock b/mix.lock index 602ecf4..4d63d30 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, }