Elixir: Easy distributed programming

Creating distributed and concurrent applications doesn’t have to be difficult. Elixir allows hobbyist and new programmers to easily create projects that can work across multiple nodes.

Elixir is a general purpose programming language that runs on top of the Erlang Virtual Machine , which is known for running low-latency, distributed, and fault-tolerant systems.

In this article I will look at three projects that will use basic Elixir functions, (no custom project setup or imported libraries).

The first projects will do remote functions between a PC and a Raspberry Pi. The second project will use multi-node requests to get Pi statistics, and the final project will look at dynamic sharing of data between three nodes.

These projects will show that distributed projects don’t have to be complicated. Each of these projects will require only 10 – 25 lines of Elixir code.

Getting Started

See the Elixir site (https://elixir-lang.org/install.html) for your specific installation.

The Elixir installation will install the Erlang VM and three new executables: iex (Interactive Elixir shell), elixir (Elixir script runner) and elixirc (Elixir compiler).

For all the projects I will keep to standard Bash command line tools so no extra Erlang or Elixir libraries will be needed.

A good first example is use the interactive Elixir shell (iex) on a Raspberry PI to write to a GPIO (General Purpose Input/Output) pin.

The iex shell is opened and the Raspberry Pi gpio command line tool is called using the base Erlang :os.cmd function:

$ iex

iex> :os.cmd(:"gpio write 7 1")
[]
iex> :os.cmd(:"gpio read 7")   
'1\n'

Elixir can call Erlang functions, just remember to place a “:” in front of the Erlang function or custom variable.

Ok that was pretty basic, the next step is to control a Pi’s GPIO from a different node.

Remote Control GPIO

A two node network can be configured by defining a username with a node address and a common cookie between the two nodes.

For my setup I logged into the Rasp Pi iex shell with a name of pi3@192.168.0.105 and I used a cookie name of “pitest”. Then I logged into the PC iex session with a name of pete@192.168.0.120 and the same cookie name of “pitest”.

From my PC iex session I only need two lines of Elixir code to remotely write to a Pi GPIO pin (Figure 2). The first line connects to the Pi Elixir node, and the second line issues a remote procedural call to run an :os.cmd statement on the Pi node:

$ iex --name pete@192.168.0.120  --cookie pitest

iex> Node.connect :"pi3@192.168.0.105"
true
iex> :rpc.call(:"pi3@192.168.0.105",:os,:cmd ,[:"gpio write 7 1"]) 
[]

It’s important to note that the underlying Erlang VM on the Raspberry Pi manages the RPC request, and for this example no Elixir code was required on the Pi node.

A Remote GPIO Write Dialog

The next step is to create a simple way to select the GPIO pin and value to write.

Elixir tends to be used for backend applications, but there are a number of good web server options and an Erlang wx module is available.

One user interface approach is to use the Elixir IO module to do text console reads and writes. To get user input the IO.gets() function is called and IO.puts will write to the console. Variables can inserted into text strings by: #{the_variable}.

iex> thepin = IO.gets("Select the Pi GPIO pin: ")
Select the Pi GPIO pin: 7
"7\n"

iex> IO.puts "Selected GPIO pin: #{thepin}" 
Selected GPIO pin: 7

For simple dialogs I like to use the command line Zenity package. Zenity support a number of different dialog types and it is pre-loaded on Raspian and most Linux OS’s.

The zenity –form command can be configured to return a GPIO pin number and pin value.

An Elixir script (Zen2gpio.exs) can be created that launches a zenity form and passes the pin data an :rpc.call function.

For this script a module Zen2gpio is created with the function show_form.

As mentioned earlier Elixir strings can have variables values inserted into strings by using: #{the_variable}.

If the user enters data and presses OK, the result string will be passed and the dialog will reopen. Pressing the Cancel button will pass no data and the script will close.

#-------------
# Zen2gpio.exs - Use a Zenity form to set the GPIO pin and value input
#-------------
defmodule Zen2gpio do
  def show_form (pnode) do
    thecmd = "zenity --forms --title='Set Pi GPIO Pins' --separator=' ' --add-entry='GPIO Pin (0-26)' --add-entry='New Value (0-1)' "
    pininfo = :os.cmd(:"#{thecmd}")
    # If some data is entered in form, write to GPIO and refresh
    if byte_size("pininfo") > 0 do
      :rpc.call(:"pi3@192.168.0.105",:os,:cmd ,[:"gpio write #{pininfo}"]) 
      show_form (pnode)
    end
  end
end
 
# Connect to the pi node
pnode = :"p3@192.168.0.105"
Node.connect  pnode

# Show the dialog
Zen2gpio.show_form(pnode)

The Elixir script runner can launch the code with the common project cookie and a unique user name.

Multi-Node RPC Requests

The goal for the next project is to have a PC node query the Pi nodes for diagnostic information. This project is a little different than the earlier project in that a module is loaded on the Raspberry Pi’s to send back custom status messages.

The Raspberry PI’s CPU temperature can be obtained using the Bash command:

# Bash command to get the Pi CPU temperature
$ /opt/vc/bin/vcgencmd measure_temp
temp=42.3'C

This Bash command can be incorporated into a small Elixir script that is loaded and compiled on each of the Pi nodes. The script (PI_stats.ex) has a module PI_stats that contains a function cpu_temp.

The cpu_temp function returns a string containing the Pi node name and the output from the shell command to get the CPU temperature.

#-----------------------------
# PI_stats.ex - Get Some Stats
#-----------------------------
defmodule PI_stats do
  def cpu_temp() do
   "#{Node.self()}  #{:os.cmd(:"/opt/vc/bin/vcgencmd measure_temp")}"
  end
  # Add more diagnostics like: available RAM, idle time ...
end

The elexirc command is used to compile an Elixir scrip. After scripts are compiled their modules are available to iex shells that are called from that directory. Below is the code to compile and then test the PI_stats module:

## compile an Elixir script
$ elixirc PI_stats.ex

## test the PI_stats.cpu_temp function locally
$ iex --name pi3@192.168.0.105  --cookie pitest
...
iex> PI_stats.cpu_temp()
{"pi3@192.168.0.105 temp=47.8\'C\n'}

An Erlang :rpc.multicall function can be used on the PC node to retrieve the Pi CPU temperatures. The :rpc.multicall function is passed the node list, module name, function call and then any addition arguments:

iex> :rpc.multicall( [:"pi3@192.168.0.105", :"pi4@192.168.0.101"], PI_stats, :cpu_temp, [])

{["pi3@192.168.0.105  temp=47.2'C\n", "pi4@192.168.0.101  temp=43.8'C\n"], []}

On the PC node a script (get_temps.exs) is created to connect to the PI nodes and get the results from the RPC multi-call. To make the code more flexible all the Pi nodes are stored in a list (pinodes). The Eum.map function will iterate the pinode list and connect to each node.

For this example the results from the RPC multi-call are a little messy so an Enum.map and Enum.join functions are used format the results to one long string that is passed to a Zenity info dialog box.

#----------------------------------------
# get_temps.exs - get PI CPU temperatures
#  - show results on Zenity Dialog
#----------------------------------------                     
pinodes = [ :"pi3@192.168.0.105", :"pi4@192.168.0.101"] 
Enum.map(pinodes, fn x-> Node.connect x end)
 
# Get results from remote PI nodes
{result,_badnodes}  = :rpc.multicall( pinodes, PI_stats, :cpu_temp, [])
 
# Format the output for a Zenity info dialog
output = Enum.map(result, fn x -> x end) |> Enum.join
:os.cmd(:"zenity --info --text=\"#{output}\" --title='Pi Diagnostics'")  

Like the earlier project the Elixir script is run with the common project cookie. and a unique user name is used.

It’s important to note that once the PI_stats.ex script is compiled on the Pi nodes no other action is required, like in the first project the RPC request is processed by the underlying Erlang VM.

Data Sharing between Nodes

Elixir offers a number of different data storage options. For simple multi-node data sharing I found that the Erlang :mnesia package to be a good fit.

For this last project Mnesia is setup to have a shared schema between the three nodes.

The Pi nodes will populate tables with their GPIO pin status every 2 seconds.

On the PC the first project will be used to write to GPIO pins, and a new script we be created to monitor the status of the GPIO pins within a Mnesia shared table.

To create a shared or distributed schema Mnesia needs to be stopped on all the nodes.

The :mnesia.create_schema function will create a shared schema for all the listed nodes.

After the schema is created Mnesia needs to be restarted. The :rpc.multicall function is extremely useful when identical actions need to done on distributed nodes:

iex> allnodes = [ :"pete@192.168.0.120" , :"pi3@192.168.0.105", :"pi4@192.168.0.101"]
iex> :rpc.multicall( allnodes, :mnesia, :stop, [])
iex> :mnesia.create_schema(allnodes)
iex> :rpc.multicall( allnodes, :mnesia, :start, [])

If you already have an existing schema, it can be deleted with by :mnesia.delete_schema([node()]).

The next step is to add tables to the schema. A table of GPIO pin values for Raspberry Pi 3 (Pi3) is created by:

iex> :mnesia.create_table(Pi3,  [attributes: [ :gpio, :value] ] )

For nodes that are writing to a specific table, it is recommended that the table be defined as both a ram and disk copy. To do this log into that node and enter:

:mnesia.change_table_copy_type(Pi3, node(), :disc_copies)

For larger projects where multiple nodes are reading and writing into tables transactions statements should be used. For small projects where only one node is writing into a table a “dirty” read and write can be used. Below is an example of writing into table Pi3 a value of 1 for pin 4, and then reading the record back.

iex> :mnesia.dirty_write({Pi3, 4,1})                                
:ok                                    
iex> pin4val = :mnesia.dirty_read({Pi3, 4}) 
[{Pi3, 4, 1}]

Now that simple writes and reads can be done the next step is to create a script that continually populates the Pi3 table with GPIO pin values.

Populating GPIO Data into a Mneisa Table

The Elixir programming language has some interesting syntax features that allow you to write some efficient code. Two features that will streamline a table input function are anonymous and enumeration functions.

Anonymous functions use the “&” to create short hard. Below is a simple example and then a complex example to read a GPIO pin value and remove the trailing new line character:

iex> # A basic example
iex> sum = &(&1 + &2)
iex> sum.(2, 3)
5

iex> getpin=&(:os.cmd(:"gpio read #{(&1)} | tr -d \"\n\" ") )
iex> getpin.(7)
'1'

The function Enum.map can do complex “for-each” loops.

These two Elixir features can be used together to read 27 Raspberry Pi GPIO pins and then write data to a Mnesia table. Below is a script (Gpio3write.exs) that will write GPIO values into a Mnesia table every 2 seconds.

#---------------
# Gpio3write.exs - Write Pi 3 GPIO values into Mnesia every 2 seconds
#---------------
defmodule Gpio3write do
  def do_write do
    getpin=&(:os.cmd(:"gpio read #{(&1)} | tr -d \"\n\" ") )
    Enum.map(0..26, fn x-> :mnesia.dirty_write({Pi3, x, getpin.(x) }) end)
    :timer.sleep(2000)
    do_write()
  end
end
# Start Mnesia
:mnesia.start()
# Cycle every 2 seconds and write values
Gpio3write.do_write()

The script on the Pi node is started by:

elixir --name pi3@192.168.0.105 --cookie pitest Gpio3write.exs

Get Records from Mnesia

Unfortunately Mnesia does not support SQL syntax, but it does support some basic filters using the dirty_match_object :

iex># Get all the records, use :_ for "all", add a sort at the end

iex> :mnesia.dirty_match_object({Pi3, :_ ,:_})  |> Enum.sort
[
  {Pi3, 0, '0'},
  {Pi3, 1, '0'},
  {Pi3, 2, '1'},
...
]
iex># Get only Pi Values that are '1'
 :mnesia.dirty_match_object({Pi3, :_ ,'1'})  |> Enum.sort
[
  {Pi3, 2, '1'},
  {Pi3, 21, '1'}
]

A Zenity list dialog can be used to show table output in columns. Below is an example of a zenity –list command in Bash. The last argument in the command is the data string which fills in the defined columns. An –extra-button option is included, and its button text is returned when the button is pressed.

A script is created (Show_gpio.exs) that reads the Mnesia results. As in the earlier example Enum.map and Enum.join functions are used to format the results as one long string for a Zenity list dialog.

#-------------------
# Show_Show_gpio.exs - show Mnesia table in a Zenity list dialog
#-------------------
defmodule Show_gpio do
  def getdata() do
    result = :mnesia.dirty_match_object({Pi3, :_ ,:_}) |> Enum.sort
    # create a presentable string for output
    output = Enum.map(result, fn x -> "#{elem(x,1)} #{elem(x,2)} " end) |> Enum.join
    feedback = :os.cmd(:"zenity --list --title=Pi3_Table --text='Pin Values' --extra-button Refresh --column=Pin --column=Value #{output}")
    if ("#{feedback}" =~ "Refresh") do
      getdata()
    end
  end
end
# Start Mnesia
:mnesia.start()
# Wait for tables to update
:timer.sleep(1000)
# Show a Zenity list dialog. 
# Refresh button to continue, other buttons to quit
Show_gpio.getdata()

Like in the earlier examples the Elixir script runner is used to call the script with the list dialog.

To test that things are working, the first project (Zen2gpio.exs) can toggle GPIO pin values, and then the Show_gpio.exs dialog can be refreshed to check the new values.

When the final project is running we have an example of Elixir concurrency. The Pi3 node is populating a Mnesia table and it is handling remote GPIO writes and CPU temperature messages.

Final Comments

Elixir with the Erlang VM offers a lot of functionality out of the box.

The next steps from here would be to start looking at messaging between nodes and creating projects with imported Elixir libraries.