Heading image for post: Programming Robot Sub Systems with Elixir

Elixir

Programming Robot Sub Systems with Elixir

Profile picture of Chris Erin

I like to dream about robots. That's a pretty broad statement so let me narrow that down. I like to dream about semi-automonous 4 wheeled rovers, moving around the surface of the moon controlled by teams of scientists, students, and hobbyists via a command line interface. OK, that's a bit more specific.

Alas, it's just a dream, but it gets me imagining different ways that Elixir and Processes might work to facilitate the robot rover's operation.

This post is about an imagined light sensing sub system. Light is sensed with a sensor and when that sensor changes it sends a message to a broker which then forwards the message to it's subscribers. I want to test this system with ExUnit, both to get a feel for how the different parts fit together and to get a feel for how to test GenServers. For my purposes this system translates into 3 GenServers:

  • LightSensor - a GenServer that interfaces with a hardware sensor and sends messages to a broker.
  • MessageBroker - a GenServer that manages subscribed processes and sends messages to those processes
  • LightServer - a GenServer that is subscribed to light messages, receives light messages and makes decisions based on those messages

In this post I will go through the code of each GenServer to show how each is implemented, how they work together and how they can be tested.

LightSensor

This GenServer would provide the interface for the hardware. I'm not really a hardware person (despite having attended the Nerves Workshop!). While I can imagine a GenServer being the interface to a sensor like this, I don't particularly care about how that's implemented.

What I do care about is that the GenServer produces a steady stream of messages that might look like what a light sensor might produce. Because I plan to test drive this with ExUnit, instead of actual hardware, I want a MockLightServer that will produce data that looks and behaves like real data.

That will look something like this:

defmodule LightSensor.Data do
  def data do
    1..100
    |> Enum.map(fn n ->
      {Enum.random(0..n), Enum.random(0..100)}
    end)
  end
end

defmodule MockLightSensor do
  use GenServer

  @name __MODULE__

  def start_link(message_broker_pid, start_end_notifier_pid) do
    result =
      {:ok, _pid} =
      GenServer.start_link(
        __MODULE__,
        %{
          message_broker: message_broker_pid,
          start_end_notifier: start_end_notifier_pid,
          data: LightSensor.Data.data()
        },
        name: @name
      )

    result
  end

  def start_messages() do
    GenServer.cast(@name, :next)
  end

  @impl true
  def init(args) do
    {:ok, args}
  end

  @impl true
  def handle_info(:next, state) do
    GenServer.cast(@name, :next)
    {:noreply, state}
  end

  @impl true
  def handle_cast(:next, %{
        message_broker: broker,
        start_end_notifier: start_end_notifier_pid,
        data: [{next_light_message, _} | []]
      }) do
    GenServer.cast(broker, {:light_message, next_light_message})
    # signal to the test process that the data sending has ended
    send(start_end_notifier_pid, :ended)
    {:noreply, %{}}
  end

  @impl true
  def handle_cast(:next, %{
        message_broker: broker,
        start_end_notifier: start_end_notifier_pid,
        data: [{interval, next_light_message} | rest]
      }) do

    Process.send_after(self(), :next, interval)
    GenServer.cast(broker, {:light_message, next_light_message})
    {:noreply, %{message_broker: broker, start_end_notifier: start_end_notifier_pid, data: rest}}
  end
end

This process has three keys in it's state map, message_broker, start_end_notifier and data.

  • message_broker - the pid that it will send light messages to
  • start_end_notifier - the pid that will be notified when there is no more data
  • data - the mock data in the format of {interval, next_light_message}

This GenServer is sending messages to three different places. The first is imitating how the light sensor might behave by sending light messages to the message broker. The second and third messages are for testing purposes. The test needs to know when it's done, so the GenServer will send a message to the test process when it's out of data. The GenServer needs to know when to send another message, so it will take the interval message from the data tuple and send a message to itself after the interval has passed.

MessageBroker

This GenServer follows a pub/sub pattern. A pub/sub GenServer here will allow many different business logic processes to subscribe to light messages without the LightSensor server needing to know or manage processes that might subscribe temporarily.

It looks like this:

defmodule MessageBroker do
  use GenServer

  @name __MODULE__

  def start_link() do
    GenServer.start_link(@name, %{subscriptions: []})
  end

  @impl true
  def init(args) do
    {:ok, args}
  end

  @impl true
  def handle_cast({:subscribe, pid}, %{subscriptions: subs}) do
    {:noreply, %{subscriptions: [pid | subs]}}
  end

  @impl true
  def handle_cast({:light_message, message}, %{subscriptions: subs}) do
    Enum.each(subs, fn sub_pid ->
      GenServer.cast(sub_pid, {:light, message})
    end)

    {:noreply, %{subscriptions: subs}}
  end
end

This version only handles two messages subscribe and light_message. subscribe messages come from interested processes and light_message messages come from the LightSensor itself.

When receiving a LightSensor message it will loop through all the subscribed processes and send a light message to each.

LightServer

The LightServer will attach meaning and behavior to the messages it receives. This is where the business logic would live. Conceivably, the LightServer would make decisions about where to move, how much energy to use, whom to notify, or maybe even when to fold or unfold the solar panels. In this example, it's pretty dumb, just recording the light messages that are above a certain value.

defmodule LightServer do
  use GenServer
  @name __MODULE__

  @impl true
  def init(args) do
    {:ok, args}
  end

  def start_link(broker) do
    {:ok, pid} = GenServer.start_link(__MODULE__, %{very_bright_measurements: []}, name: @name)
    GenServer.cast(broker, {:subscribe, pid})
    {:ok, pid}
  end

  def get_very_hot_temperatures do
    GenServer.call(@name, :get_very_hot_temperatures)
  end

  @impl true
  def handle_cast({:light, light_amount}, %{very_bright_measurements: amounts}) when light_amount > 90 do
    {:noreply, %{very_bright_measurements: [light_amount | amounts]}}
  end

  @impl true
  def handle_cast({:light, _light_amount}, state) do
    # do nothing
    {:noreply, state}
  end

  @impl true
  def handle_call(:get_very_bright_measurements, _from, %{very_bright_measurements: amounts} = state) do
    {:reply, amounts, state}
  end
end

Testing the Sub System

What makes programming this subsystem fun is being able to test it. I want to test that the LightServer has data when all the processes have been sent.

The ExUnit test will look like this:

defmodule LightSensorTest do
  use ExUnit.Case

  setup do
    {:ok, message_broker_pid} = MessageBroker.start_link()
    {:ok, _pid} = MockLightSensor.start_link(message_broker_pid, self())
    {:ok, _pid} = LightServer.start_link(message_broker_pid)
  end

  test "Does Light Server receive messages?" do
    MockLightSensor.start_messages()
    assert_receive(:ended, 10000)
    assert very_bright_measurements = LightServer.get_very_bright_measurements()
    assert(length(very_bright_measurements) > 0)
  end
end

It's a simple test, just assert that the LightServer has received the expected data. This assertion will fail, however, without allowing all the messages to fly around and be processed. Because the messages are almost all cast (read: asynchronous) the test would reach it's end before anything had happened without the assert_received call. This function waits for a specific message to be sent to the test process in a certain amount of time. In this case, we wait 10 seconds for all the messages to be processed and for the MockLightSensor to send the :ended message to the test process.

Conclusion

This imagined subsystem isn't imagined exhaustively. It's just a sketch of something that might exist in a future rover robot. It's an exercise that I can use to get better at understanding Erlang/Elixir process patterns and to get better at testing processes in general. In the future it might be fun to imagine other rover robot subsystems.


Image via NASA

More posts about Elixir testing erlang