Lucas Sifoni

The Elixir Telescope
Part 7 : Elixir architecture questions

elixir


Entries up to this point :

While working on the adaptation of the current virtual telescope and remote control to be able to control a physical telescope, I oscillated between a few design choices.

My first instinct was the introduction of a configurable InputManager that would talk to a TelescopeManager. The InputManager would filter virtual and physical inputs based on its current configuration, and pass events to a TelescopeManager that would send them to physical or virtual telescopes.

Would that work ? Yes, to an extent. But there are a few misconceptions on my side. The physical remote control will have a screen on it, and the virtual remote control + scope visualization reacts to state. But on this drawing, they are both shown as input devices, and with a bidirectional arrow to their transport layer. They are more to be seen as I/O devices.

Here, we see the TelescopeManager passes inputs only to the enabled telescope, which sends outputs back, propagated via the IoManager only to the enabled device. That seems more to the point, but does it express my original intent ?

I wanted to have 4 IO devices :

To be able to develop as I go with combinations like virtual remote + physical telescope. But what was developed to this point is a virtual telescope and remote control, and what is currently built is a physical telescope and remote control. That cannot entirely be seen in the diagram, because the virtual telescope shares its transport layer with the virtual remote control, and the physical telescope shares its transport layer with the remote control.

Here is a rearrangement that could show that :

Quite gross ! Thankfully I didn’t write code that far before realizing there was a problem.

Let’s reexamine the role of the InputManager or IoManager :

Maybe what I would like instead is compile-time configurability. That sounds great, let’s write a simple POC.

defmodule IoBehaviour do
  @callback input(String.t) :: {:ok, String.t} | {:error, String.t}
end

defmodule DeviceBehaviour do
  @callback handle_input(String.t) :: {:ok, String.t} | {:error, String.t}
end

defmodule DeviceA do
  @behaviour DeviceBehaviour

  def handle_input(string) do
    if :rand.uniform() > 0.5 do
      {:ok, "Got #{string}"}
    else
      {:error, "Unsupported operation #{string}"}
    end
  end
end

# A similar "DeviceB" module exists
defmodule IoA do
  @behaviour IoBehaviour

  def input(str) do
    InputManager.input(__MODULE__, str)
  end
end

# A similar "IoB" module exists

defmodule InputManager do
  @io_device IoB
  @reactive_device DeviceB

  def input(@io_device, str), do: apply(@reactive_device, :handle_input, [str])

  def input(mod, _), do: {:error, "IO device #{mod} not configured."}
end

That is “configurable” since we can say which IO module is connected to which device.

iex(17)> IoB.input("foo")
{:ok, "Received foo"}
iex(18)> IoA.input("foo")
{:error, "IO device Elixir.IoA not configured."}

But we still have a central InputManager. Maybe we could juste compile-time remove it. Here’s a first try :

# config.exs
import Config

config :confpoc,
  selected_io: IoA,
  selected_device: DeviceB

We’ll then define an IoMacro and a DeviceMacro. Both of those modules add catch-all implementations for their communication bricks, input and handle_notify for Io modules, and handle_input and notify for Device modules. There’s something very similar in all of that, and I could maybe simplify it again. But those two macros wire selected Io and Device modules together, and isolate the unselected ones.

defmodule IoMacro do
  defmacro __using__(_) do
    selected_io = Application.fetch_env!(:confpoc, :selected_io)
    selected_reactive_device = Application.fetch_env!(:confpoc, :selected_device)
    if __CALLER__.module != selected_io do
      quote do
        defp input(term), do: {:error, "IO Device not available"}
        defp input(term, payload), do: {:error, "IO Device not available"}
        def handle_notify(term), do: {:error, "IO Device not available"}
        def handle_notify(term, payload), do: {:error, "IO Device not available"}
      end
    else
      quote do
        defp input(term), do: apply(unquote(selected_reactive_device), :handle_input, [term])
        defp input(term, payload), do: apply(unquote(selected_reactive_device), :handle_input, [term, payload])
      end
    end
  end
end

defmodule DeviceMacro do
  defmacro __using__(_) do
    selected_io = Application.fetch_env!(:confpoc, :selected_io)
    selected_reactive_device = Application.fetch_env!(:confpoc, :selected_device)
    if __CALLER__.module != selected_reactive_device do
      quote do
        defp notify(term), do: {:error, "Reactive device not available"}
        defp notify(term, payload), do: {:error, "Reactive device not available"}
        def handle_input(term), do: {:error, "Reactive device not available"}
        def handle_input(term, payload), do: {:error, "Reactive device not available"}
      end
    else
      quote do
        defp notify(term), do: apply(unquote(selected_io), :handle_notify, [term])
        defp notify(term, payload), do: apply(unquote(selected_io), :handle_notify, [term, payload])
      end
    end
  end
end

A sample implementation of the modules :

defmodule IoA do
  use IoMacro

  def take_a_picture(), do: input(:take_a_picture)
  def handle_notify(term), do: IO.inspect(["IoA got:" , term])
end

defmodule DeviceB do
  use DeviceMacro
  
  def handle_input(:take_a_picture), do: notify(:heres_your_picture)
  def handle_input(_), do: :ok
end

Now, whenever IoA.take_a_picture/0 is called, DeviceB.handle_input(:take_a_picture) is really called. A call to notify/1 in DeviceB really calls IoA.handle_notify/1, and our middleman InputManager is gone. It is gone, and the design is now a 1-to-1 mapping between an active IO device and a reactive device, which is what I wanted to start with.

iex(15)> IoA.take_a_picture
["IoA got:", :heres_your_picture]
["IoA got:", :heres_your_picture]
iex(16)> IoB.take_a_picture
{:error, "IO Device not available"}

Is it a final design ? I don’t know yet, but it’s simpler and removes unneeded complexity. By being configurable with a traditional config.exs script, it also feels more idiomatic and reflects the reality of a dev environment with a virtual i/o device and telescope, and a prod environment where hardware can be real, while having both implementations co-located.

What I like here is that there is no idea of synchronous or asynchronous work. If a device needs to respond synchronously, it can just call notify in its handle_input callback. If there’s a future notification to be emitted after some work, it can notify later. That’s a detail and not part of the contract, since communication is just a function call on another module. I also like being able to defp the input and notify functions to limit the temptation to call them from outside their boundaries and should explore better ways of enforcing this isolation.


Previous post : The Elixir Telescope -- Part 6 : Three.js + websockets = a 3D moving telescope
Next post : The Elixir Telescope -- Part 8 : Controlling motors from Elixir