4 minute read

Who This Blog Post Is For: This blog post is for Elixir developers, hobbyists interested in real-time APIs, or anyone, like myself, who has never seen a WebSocket API before.

Living on the bank of the Detroit River, I watch massive ships pass by every day. While MarineTraffic can help identify these vessels, I thought: why not build my own display to show real-time ship data? This project began with exploring the AIS Stream WebSocket API, my first step in combining Elixir with maritime data.

A ship going down the Detroit river
A freighter navigating the Detroit river. What's its name? Where is it headed? And what's its story?

AIS (Automatic Identification System)

Large ships send out their position information over radio using a system called AIS (Automatic idenfitication system). AIS radio receivers collect data from nearby ships and can share it online, creating a global network of maritime traffic data. By consuming this data via APIs, you can gain insights into ship movements worldwide—or in your backyard.

I chose to explore the AIS Stream WebSocket API to process live AIS messages in real-time. This API provides a WebSocket interface for receiving real-time ship data, which makes it an appropriate project to tackle using Elixir. Elixir is particularly well-suited for consuming real-time APIs over WebSockets, and its advantages stem from its robust concurrency model, lightweight processes, and the fault-tolerant runtime of the underlying Erlang VM (BEAM).

Setting Up the Project

First, I created a new Elixir project:

mix new ais_stream_client
cd ais_stream_client

Then, I added the websockex library to my dependencies for WebSocket communication and poison for JSON encoding and decoding. Here’s how the mix.exs file looked:

defp deps do
  [
    {:websockex, "~> 0.4.3"},
    {:poison, "~> 6.0"}
  ]
end

Run mix deps.get to fetch the dependencies.

Creating the WebSocket Client

Using websockex, I created a simple WebSocket client module that connects to the AIS Stream WebSocket API and processes incoming messages. WebSockex works like other Elixir behaviours (like GenServer or Supervisor)–you need to implement some callbacks to start using it.

There are only 3 things you need to do to get started with Websockex.

  1. Call WebSockex.start_link with the URL of the API you’re calling.
  2. Authenticate yourself by sending a message (with your API key) over the websocket to the API.
  3. Implement the WebSockex behaviour and in particular, the handle_frame callback to handle any messages the API sends you.

Check out the docs for WebSockex, they’re useful.

Here’s the code to implement a WebSockex behaviour, the bare minimum to get started:

defmodule AISStreamClient do
  use WebSockex

  @url "wss://stream.aisstream.io/v0/stream" # WebSocket URL

  # Start the WebSocket client
  def start_link(initial_state \\ %{}) do
    WebSockex.start_link(@url, __MODULE__, initial_state, name: __MODULE__)
  end

  # Handle incoming messages
  def handle_frame({:binary, msg}, state) do
    case Poison.decode(msg) do
      {:ok, decoded_msg} ->
        IO.inspect(decoded_msg, label: "Decoded AIS Data")
        {:ok, state}

      {:error, reason} ->
        IO.puts("Failed to decode JSON: #{inspect(reason)}")
        {:ok, state}
    end
  end

  # Handle other frames not covered by the above pattern matching
  def handle_frame(frame, state) do
    IO.puts("Unhandled frame: #{inspect(frame)}")
    {:ok, state}
  end
end

A few notes:

  1. __MODULE__ is a compile-time macro that represents the name of the current module, in this case, AISStreamClient.
  2. I’m not using the initial_state argument in the call to start_link, or the changing the state before it’s returned from handle_frame, but this is the convention of managing state found in OTP behaviours. The state is a way to store information between messages—for example, connection settings or processed data. Even though I don’t update it in this example, following this pattern ensures your client can scale to handle more complex tasks later.

Connecting and Sending the Subscription Key

To authenticate with the AIS Stream WebSocket API, I needed a subscription key. To filter AIS data to a specific geographic area, the API requires a bounding box, defined by latitude and longitude coordinates. Here’s how I started the client and sent the key and bounding boxes:

defmodule AISStreamApp do
  def start do
    {:ok, pid} = AISStreamClient.start_link(%{})

    data = %{
      # Replace with your actual API key from AISStream
      APIKey: "your_subscription_key",

      # Bounding box covering the Detroit River
      BoundingBoxes: [
        [[42.414173, -82.907625], [42.037176, -83.250223]]
      ]
    }

    # Send the API key as JSON
    WebSockex.send_frame(pid, {:text, Poison.encode!(data)})
  end
end

Run the application using:

iex -S mix
AISStreamApp.start()

Processing AIS Data

I was able to successfully receive and process real-time AIS data. Here’s an example of the decoded JSON message:

Decoded AIS Data: %{
  "Message" => %{
    "PositionReport" => %{
      "Cog" => 91,
      "CommunicationState" => 59916,
      "Latitude" => 42.30840333333333,
      "Longitude" => -83.083435,
      "MessageID" => 3,
      "NavigationalStatus" => 5,
      "PositionAccuracy" => true,
      "Raim" => false,
      "RateOfTurn" => 0,
      "RepeatIndicator" => 0,
      "Sog" => 0,
      "Spare" => 0,
      "SpecialManoeuvreIndicator" => 0,
      "Timestamp" => 42,
      "TrueHeading" => 43,
      "UserID" => 538006783,
      "Valid" => true
    }
  },
  "MessageType" => "PositionReport",
  "MetaData" => %{
    "MMSI" => 538006783,
    "MMSI_String" => 538006783,
    "ShipName" => "FEDERAL OSHIMA      ",
    "latitude" => 42.30840333333333,
    "longitude" => -83.083435,
    "time_utc" => "2024-12-09 02:27:43.237370229 +0000 UTC"
  }
}

This is great, and exactly what I wanted. However, I was receiving data intermittently; on some days I would receive information and on others I would not. I wonder if this is due to the intermittent running of the AIS radio receivers. (e.g., Maybe it was turned off?)

Oh well! I’ll keep an eye on it, and hopefully it will work out for me. I’ll try another API next time!

Conclusion

While I’m not certain that the API will work for my specific needs, building a WebSocket client in Elixir to consume real-time ship data was a great learning experience. If you’re working with WebSockets in Elixir, libraries like websockex and poison make the process straightforward.

You can check out AIS Stream’s documentation to explore their API further. Let me know if you have any questions or tips for improving WebSocket clients in Elixir!

Note: This is just the beginning! In future posts, I’ll explore other AIS APIs and ways to integrate this data into a custom physical display. Stay tuned.