Rage Against The Finite State Machine

October 13, 2017
elixir erlang badgelife skydogcon

Rage Against the Finite State Machine

Now that we have the start of a server that can receive messages from the badges over UDP, it’s time to talk about the requirements for the badges themselves.

We want apps! (Plural).

To keep the codebase as clean as possible I want the actual applications that run on the badge to run in separate processes. Then they can be developed, modelled, and tested separately.

Why the Finite State Machine?

The process that represents the badge has to be able to do two things:

  1. Have a way to choose which application to run.
  2. Route the Button/Joystick actions from the badge to the correct application and the screen image from the application back to the badge for display.

The state of affairs.

The badge’s state is always one of:

  1. :initial - Shows Serial Number, IP address/port, and instructions.
  2. :menu - In a menu, the joystick operates the menu.
  3. SDC2017.<ApplicationName> - In an application, the joystick actions are routed to the application process for that badge.

Structuring it this way should make it really easy to reason about.

Show me the code!

defmodule SDC2017.Badge do
  use GenStateMachine, callback_mode: :handle_event_function

  def start_link(badgeid, ipport) do
    GenStateMachine.start_link(__MODULE__, %{id: badgeid, ipport: ipport, option: 0, fb: <<>>}, name: String.to_atom(badgeid))
  end

  def init(data) do
    {:ok, :initial, data}
  end

start_link starts with the badgeid and the source ip/port. We pass the Source IP/Port for display purposes only. When we send images back to the badge we do it by sending a message to the SDC2017.UDP server with the badgeid and that process is responsible for the network-layer routing.

Point of Order: When using an FSM vs GenServer we have a terminology collision. In GenServers we refer to the data that the process stores internally as its ‘state’. In an FSM ‘state’ means something different so, to save the confusion - for this module, we will use the terms ‘state’ and ‘data’.

The ‘data’ that is stored in the FSM consists of:

  1. The Badgeid (because, it’s always nice to know our own name).
  2. The remote IP/Port (for display purposes only).
  3. The currently selected option in the menu (which is implemented in this module).
  4. The xpm data for the last frame the badge displayed.

Having our FSM react to stimuli.

We list all of the combinations of stimuli that the badge process can receive and specify what happens. Let’s start with the easy one:

  def handle_event(:cast, {"COLDBOOT", ipport}, state, data), do: render_noapp(ipport, state, data)

The above function is called if we receive a “COLDBOOT” message in ANY state. The means that if the badge has rebooted, we will always drop the new rebooted badge into this :initial state.

The render_noapp() function generates a page that displays the badge serial number, external IP address, port, and instructions to press “B”.

  def render_noapp(ipport = {{a,b,c,d},port}, state, data = %{id: badgeid}) do
    bindata = SDC2017.Tbox.cls
    |> SDC2017.Tbox.print(%{x: 0, y: 0}, "ID: #{badgeid}")
    |> SDC2017.Tbox.print(%{x: 0, y: 1}, "IP: #{a}.#{b}.#{c}.#{d}")
    |> SDC2017.Tbox.print(%{x: 0, y: 2}, "PT: #{port}")
    |> SDC2017.Tbox.print(%{x: 0, y: 4}, "Connected: evil.red")
    |> SDC2017.Tbox.print(%{x: 0, y: 6}, "Press \"B\" for menu")
    |> SDC2017.Tbox.pp
    |> SDC2017.OLED.render

bindata now contains the 128x64x1 bitmap of the screen of text, so we need to send it to the badge and cache it like so:

    GenServer.cast(SDC2017.UDP, {:display, badgeid, bindata})
    newstate = Map.put(data, :fb, bindata)
    {:next_state, :initial, newstate}
  end

… and that’s it, now when we power on the badge we have our initial screen.

So let’s describe functionally how we want the menus to work.

  1. If you press the “B” button, we switch the menu mode (“BD”). (ie, :menu state)
  2. If you hold the “B” button and use the joystick to go up and down we move the cursor in the menu (“UD” and “DD”). (stay in :menu state)
  3. If you let go of the “B” button, the badge will switch to whichever application was selected. (Change our state to the code modulename of the application)

Button Down! (“BD”)

def handle_event(:cast, {"BD", {ip, uport}}, state, data), do: render_menu(data)

So, if we receive a “BD” in ANY state, we execute render_menu() which does pretty much what you’d expect:

  def render_menu(data = %{id: badgeid, ipport: ipport, option: menuoption}) do
    bindata = SDC2017.Tbox.cls
    |> SDC2017.Tbox.print(%{x: 0, y: 0}, "     Main Menu    ")
    |> SDC2017.Tbox.print(%{x: 0, y: 2}, "  Test Application")
    |> SDC2017.Tbox.print(%{x: 0, y: 3}, "  Schedule        ")
    |> SDC2017.Tbox.print(%{x: 0, y: 4}, "  Twitter Feed    ")
    |> SDC2017.Tbox.print(%{x: 0, y: 5}, "  Compose Tweet   ")
    |> SDC2017.Tbox.print(%{x: 0, y: (menuoption + 2)}, ">")
    |> SDC2017.Tbox.pp
    |> SDC2017.OLED.render

Much like the render_noapp function above, this is just a display (but note the cursor which takes the “option” value and adds a “>” next to the application that’s currently selected.

So, send to the badge, cache the screen and change the state to :menu.

    GenServer.cast(SDC2017.UDP, {:display, badgeid, bindata})
    newdata = Map.put(data, :fb, bindata)
    {:next_state, :menu, newdata}
  end

Moving in the menu!

To move our cursor in the menu we need to react to the joystick movements only when in the :menu state like so:

For down:

  def handle_event(:cast, {"DD", ipport}, :menu, data = %{option: menuoption}) do

Note, by specifying the :menu, this function will only be called while in the menu. The function moves cursor to the next application in the list (with looping).

Joystick up is implemented the exact same way.

Executing our application!

From our description above we want to switch the badge to the application state when the button is released (“BU”). This is a little more intricate:

  def handle_event(:cast, {"BU", {ip, uport}}, state, data), do: switch_app(data)

Of course, the switch_app function is where it’s most interesting, but before we go there we need to map the applications in the menu to the code modules:

  def app(0), do: SDC2017.Test             # Test Application
  def app(1), do: SDC2017.Schedule         # Schedule
  def app(2), do: SDC2017.Twitter          # Twitter Feed
  def app(3), do: SDC2017.TwitterSend      # Compose Tweet

So, let’s switch some apps!

  def switch_app(data = %{id: badgeid, option: menuoption}) do
    appmodule = app(menuoption)

So, appmodule now contains the modulename for the application we’re about to run.

    mypid =
    case Registry.match(:badgeapps, badgeid, appmodule) do
      [] -> {:ok, pid} = apply(appmodule, :start_link, [badgeid])
            pid
      [{pid, appmodule}] -> pid
    end

The above looks up in the registry whether an application process for this specific badge exists already. If it does, it returns its pid (Process Identifier). If it doesn’t, it creates it and returns the pid.

    bindata = GenServer.call(mypid, {:payload, :refresh})

This is a syncronous call to that application that has a payload of {:payload, :refresh}. On execution, the application should return us an xpm.

    newdata = Map.put(data, :fb, bindata)
    GenServer.cast(SDC2017.UDP, {:display, badgeid, bindata})

… which we dutifully cache and send back to the badge.

    {:next_state, appmodule, newdata}
  end

As our final act, we set the state to the code modulename for the application that’s running.

Routing for the application

The final bit of functionality is to pass any joystick movements to the correct application process. Since the state is the modulename, it makes it really easy to track:

  def handle_event(:cast, {payload, ipport}, state, data = %{id: badgeid}) do
    newpid =
    case Registry.match(:badgeapps, badgeid, state) do
      [] -> {:ok, pid} = apply(state, :start_link, [badgeid])
            pid
      [{pid, appmodule}] -> pid
    end

Identical to before. Look up the application process for that badge’s pid or, failing that start a new one.

    bindata = GenServer.call(newpid, {:payload, payload})

We are quite literally passing whatever message this badge-process received directly to that application process (unless it’s a “B” button press).

    GenServer.cast(SDC2017.UDP, {:display, badgeid, bindata})
    newstate = Map.put(data, :fb, bindata)
    {:next_state, state, newstate}
  end

… and again, send the image to the badge, cache the image data and keep the state as the same application.

Conclusion

Being able to use the GenStateMachine makes it a lot easier to map your real-world functionality into code. I encourage you to put it in your toolbox.

The full source for this module (and the rest of the project) can be found here: https://github.com/redvers/sdc2017/blob/master/lib/sdc2017/badge.ex