Current section

Files

Jump to
aprs_utils lib aprs_is.ex
Raw

lib/aprs_is.ex

defmodule AprsUtils.AprsIs do
@moduledoc """
A module to connect to an APRS-IS server and listen for packets.
These functions implement a client for the APRS-IS server. To use this module, you must implement a
client module that implements the AprsUtils.AprsIsClient behaviour. The client module will be called
when packets are recieved from the APRS-IS server.
## Example
defmodule Client do
@behaviour AprsUtils.AprsIsClient
def got_packet(packet, _packet_count) do
IO.puts("Got packet: \#{String.replace_invalid(packet)}")
end
def got_comment(comment) do
IO.puts("Got comment: \#{String.replace_invalid(comment)}")
end
def disconnected(reason) do
IO.puts("Disconnected: \#{reason}")
end
end
{:ok, aprs_is_pid} =
AprsIs.connect(
username: "my_call_sign",
password: "my_aprs_is_password",
app_name: "MyAppName",
app_version: "0.1",
client_module: Client
)
Note to connect to APRS-IS take a look at [Connecting to APRS-IS](http://www.aprs-is.net/Connecting.aspx). Also, please
follow the [rules and guidelines](https://www.aprs-is.net/Default.aspx) for using APRS-IS.
"""
@doc """
Connects to the APRS-IS server and starts listening for packets.
`opts` may contain the following fields:
- :host - The hostname of the APRS-IS server. Defaults to "rotate.aprs.net".
- :port - The port of the APRS-IS server. Defaults to 14580.
- :username - The username (typically your CALLSIGN) to connect to the APRS-IS server.
- :password - The password to connect to the APRS-IS server.
- :app_name - The name of your application.
- :app_version - The version of your application.
- :filter - The filter. See the docs [here](https://www.aprs-is.net/javAPRSFilter.aspx) to use for the APRS-IS server. Defaults to `"t/poimqstunw"`.
- :client_module - The module that implements the AprsUtilsIsClient behaviour.
All of the fields are required except for `:host`, `:port`, and `:filter` which have defaults.
Returns `{:ok, pid}` if the connection is successful. The `pid` is the pid of the listener process that is spawned by a successful call.
Returns `{:error, reason}` if the connection fails. `reason` is a binary describing the error.
> Note that only one connection can be made to the APRS-IS server. It was intended that multiple connections could be made to the APRS-IS server, but this does
> not work as of this release.
"""
def connect(opts) do
case validate_opts(opts) do
{:ok,
%{
host: host,
port: port,
username: username,
password: password,
app_name: app_name,
app_version: app_version,
filter: filter,
client_module: client_module
}} ->
start_server(host, port, username, password, app_name, app_version, filter, client_module)
{:error, reason} ->
{:error, reason}
end
end
@doc """
Returns `true` if the APRS-IS server is connected, `false` otherwise. The `aprs_is_pid` is the pid returned by `connect`.
"""
def is_connected?(aprs_is_pid) do
Process.alive?(aprs_is_pid)
end
@doc """
Closes the connection to the APRS-IS server. The `aprs_is_pid` is the pid returned by `connect`. Note that calling this does not
cause a call to the `disconnected` callback in the client module.
"""
def close(aprs_is_pid) do
Process.exit(aprs_is_pid, :kill)
end
# Private functions
defp validate_opts(opts) do
{host, opts} = Keyword.pop(opts, :host, "rotate.aprs.net")
{port, opts} = Keyword.pop(opts, :port, 14580)
{username, opts} = Keyword.pop(opts, :username)
{password, opts} = Keyword.pop(opts, :password)
{app_name, opts} = Keyword.pop(opts, :app_name)
{app_version, opts} = Keyword.pop(opts, :app_version)
{filter, opts} = Keyword.pop(opts, :filter, "t/poimqstunw")
{client_module, opts} = Keyword.pop(opts, :client_module)
try do
if opts != [], do: throw("Unknown options: #{inspect(opts)}")
if username == nil, do: throw("Username is required")
if password == nil, do: throw("Password is required")
if app_name == nil, do: throw("App name is required")
if app_version == nil, do: throw("App version is required")
if client_module == nil, do: throw("Client module is required")
{:ok,
%{
host: host,
port: port,
username: username,
password: password,
app_name: app_name,
app_version: app_version,
filter: filter,
client_module: client_module
}}
catch
reason -> {:error, reason}
end
end
defp start_server(host, port, username, password, app_name, app_version, filter, client_module) do
case :gen_tcp.connect(
String.to_charlist(host),
port,
[
:binary,
:inet,
active: false,
packet: :line
],
3000
) do
{:ok, socket} ->
with :ok <-
:gen_tcp.send(
socket,
"user #{username} pass #{password} #{app_name} #{app_version} filter #{filter}\r\n"
),
{:ok, <<"# ", _server_version::binary>>} <- :gen_tcp.recv(socket, 0, 3000),
{:ok, login_response} <- :gen_tcp.recv(socket, 0, 3000),
true <-
(if String.starts_with?(login_response, "# logresp #{username} verified") do
true
else
{:error, "username/password not recognized"}
end) do
listener = spawn(fn -> listen(socket, client_module) end)
{:ok, listener}
else
{:error, reason} ->
:gen_tcp.close(socket)
{:error, reason}
end
{:error, reason} ->
{:error, reason}
end
end
defp listen(socket, client_module), do: listen(socket, client_module, 0)
defp listen(socket, client_module, packet_count) do
case :gen_tcp.recv(socket, 0) do
{:error, reason} ->
client_module.disconnected(reason)
{:ok, data} ->
got(data, client_module, packet_count + 1)
listen(socket, client_module, packet_count + 1)
end
end
defp got(<<"# ", str::binary>>, client_module, _packet_count) do
client_module.got_comment(String.trim(str))
end
defp got(str, client_module, packet_count) do
client_module.got_packet(String.trim(str), packet_count)
end
end
defmodule AprsUtils.AprsIsClient do
@moduledoc """
Callbacks for the AprsUtils.AprsIs client module
"""
@doc """
Called when a packet is recieved from the APRS-IS server. Note that `packet` is
a binary, but not necessarily a valid `String`. `packet_count` is the number of packets
recieved since the connection was established. The return value is ignored.
"""
@callback got_packet(packet :: binary(), packet_count :: integer()) :: :ok
@doc """
Called when a comment is recieved from the APRS-IS server. Note that `comment` is
a binary, but not necessarily a valid `String`. The return value is ignored.
"""
@callback got_comment(comment :: binary()) :: :ok
@doc """
Called when the connection to the APRS-IS server is disconnected. `reason` is a term
describing the reason for the disconnection. The return value is ignored.
"""
@callback disconnected(reason :: term()) :: :ok
end