Building a Raspberry Pi weather station with Elixir/Nerves – Part 4

Part 3 demonstrated how to communicate with the wind speed sensor. The next step is to create modules for reading from both sensors, and automatically repeating this task at a set interval.

You will notice two project names in the details below, Lake Effect and Thunder Snow. These are the project names for the code running on the weather station (Lake Effect) and the code that will be running the API/UI for display (Thunder Snow). Currently Thunder Snow is empty, that will change after Part 4.

 You down with OTP? Yea you know me!

I am going to utilize OTP to implement the processes for reading data and printing to the screen. There will be a total of 4 servers, all supervised by the default Application supervisor:

Name Description
LakeEffect.Sensors.Temperature.Server Read temperature
LakeEffect.Sensors.WindSpeed.Server Read wind speed
LakeEffect.Clients.ThunderSnow.Server Communicate results to a server running Thunder Snow
LakeEffect.Jobs.Server Orchistrate weather information gathering and persisting

Reading from the sensors will be broken out into three modules:

I initially found this approach from Dave Thomas, coined Separating Exection Strategy from Logic. You can read more about it on his blog. For those who attended the hacking session at LoneStar Elixir 2018, he explained this approach in more detail.

I placed the modules which read from hardware under the lib/lake_effect/sensors directory. The structure that I ended up with is shown below. For each sensor, the root most module (temperature.ex and wind_speed.ex) are the API’s that should be called. Implementation details are stored in the respective impl.ex, while GenServer’s shall be stored in server.ex.

├── sensors
│   ├── temperature
│   │   ├── impl.ex
│   │   └── server.ex
│   ├── temperature.ex
│   ├── wind_speed
│   │   ├── impl.ex
│   │   └── server.ex
│   └── wind_speed.ex

 Temperature Sensor

Let’s start with temperature.ex, as it’s the module that will be called to retrieve the current temperature. A single function is available (read/0), returning a type of Float, containing the current temperature in *C. The function calls a GenServer named :temperature_sensor which is used to interface with the implementation that does the heavy lifting.

# lib/lake_effect/sensors/temperature.ex
defmodule LakeEffect.Sensors.Temperature do
  @spec read() :: Float.t()
  def read() do
    GenServer.call(:temperature_sensor, :read)
  end
end

The GenServer is very small, as it calls out to the Impl module to perform the work. I provided a name in the start_link params to remove the need to save the pid, it will work nicely in this case. There is no state to save between calls to the GenServer, so an empty map (%{}) is used.

# lib/lake_effect/sensors/temperature/server.ex
defmodule LakeEffect.Sensors.Temperature.Server do
  @moduledoc false
  use GenServer
  alias LakeEffect.Sensors.Temperature.Impl

  def start_link() do
    GenServer.start_link(__MODULE__, [], name: :temperature_sensor)
  end

  def init(_) do
    {:ok, %{}}
  end

  def handle_call(:read, _from, _state) do
    {:reply, Impl.read(), %{}}
  end
end

All of the implementation details for reading from the temperature sensor are located within this module. Each module attribute contains a piece of information required to build the file path that is read to get information from the sensor. Yes, it is that easy, you are simply reading from a file 🙂

Once the file is opened, and read, it must be parsed to extract the interesting data. A regular expression is used to parse the text, and provide us with the temperature. It is then parsed into a float, converted into *C before being returned.

The format of the data you will find in the file is shown in the comments, the value after the t= is the only portion needed.

# xx xx xx xx xx xx xx xx xx : crc=2c YES
# xx xx xx xx xx xx xx xx xx t=22187
#
# lib/lake_effect/sensors/temperature/impl.ex
defmodule LakeEffect.Sensors.Temperature.Impl do
  @moduledoc false

  @sensor_base_dir "/sys/bus/w1/devices/"
  @sensor_id "28-0000081bfd1c"
  @sensor_path "#{@sensor_base_dir}#{@sensor_id}/w1_slave"

  @spec read() :: float
  def read() do
    as_binary(@sensor_path)
    |> parse_temp()
  end

  defp as_binary(path) do
    path
    |> File.read!()
  end

  defp parse_temp(data) do
    {temp, _} =
      Regex.run(~r/t=(\d+)/, data)
      |> List.last()
      |> Float.parse()

    temp / 1000
  end
end

 Wind Speed Sensor

To retrieve the wind speed, communication must be done over the SPI (Serial Peripheral Interface) bus. The MCP3008 analog to digital chip is the hardware we will be talking to as a mediator between us and the wind speed sensor. Elixir Ale is an open source library that makes it very easy to communicate over the SPI interface. Module structure is the same as the temperature sensor. The major difference is the Impl module, and we will look at that first.

Elixir Ale requires start_link to be called and the device we want to communicate with provided as a parameter. The resulting pid must be returned from new/0 for our GenServer to save in its state.

If you read part 3 the rest should be familiar, with minor refactoring. read_wind_speed/1 is the function the GenServer will call, along with passing the pid of the SPI process, to retrieve the current wind speed. It pipes through building the SPI payload, sending, reading, and converting. One of my todo’s is to clean this up a bit, but its not a major concern of mine at this time.

defmodule LakeEffect.Sensors.WindSpeed.Impl do
  @moduledoc false
  alias ElixirALE.SPI
  alias LakeEffect.Convert

  def new() do
    {:ok, pid} = SPI.start_link("spidev0.0")
    pid
  end

  def read_wind_speed(pid) do
    pid
    |> spi_transfer(transmission_payload())
    |> spi_read
    |> Convert.from_counts_to_volts()
    |> Convert.from_voltage_to_ms()
    |> Convert.from_ms_to_mph()
  end

  defp transmission_payload(), do: <<0x01, 0x80, 0x00>>

  defp spi_transfer(pid, payload) do
    pid
    |> SPI.transfer(payload)
  end

  defp spi_read(<<_::size(14), counts::size(10)>>) do
    counts
  end
end

Next let’s take a look at the wind speed GenServer. I name the GenServer within start_link/0, and next we need to build the state. init/1 returns a tuple with the state containing the pid from the Elixir Ale SPI GenServer. This is required for when handle_call/3 is called, the pid will be used as an argument for read_wind_speed/1 which returns the current wind speed.

defmodule LakeEffect.Sensors.WindSpeed.Server do
  @moduledoc false
  use GenServer
  alias LakeEffect.Sensors.WindSpeed.Impl

  def start_link() do
    GenServer.start_link(__MODULE__, [], name: :wind_speed_sensor)
  end

  def init(_) do
    {:ok, %{ale: Impl.new()}}
  end

  def handle_call(:read, _from, state) do
    {:reply, Impl.read_wind_speed(state[:ale]), state}
  end
end

This is all fine and dandy, but how do I call all of this? The final step in the sensor journey is to show you the API. The documentation has been removed from the source in this post, but is available in the LakeEffect Gitlab project and the LakeEffect ExDocs.

Calling read/0 is all the application is required to call when the wind speed is needed. All of the implementation and server details are hidden away.

 But wait, there’s more

Calling read/0 on both sensors is easy, but I wanted to make it more convenient. A central location to retrieve both pieces of weather data, would be ideal. I created LakeEffect.WeatherInfo which returns a map of weather data.

defmodule LakeEffect.WeatherInfo do
  alias LakeEffect.Sensors.WindSpeed
  alias LakeEffect.Sensors.Temperature
  alias LakeEffect.Clients.ThunderSnow

  @typedoc "Map describing the data read from the sensors"
  @type t :: %{wind_speed: number, temperature: number}

  @spec collect :: WeatherInfo.t()
  def collect do
    %{wind_speed: WindSpeed.read(), temperature: Temperature.read()}
  end

  @spec save(WeatherInfo.t()) :: term
  def save(sensor_data) do
    ThunderSnow.send(sensor_data)
  end
end

 Reading sensors at a specific interval

Now that I can read from the sensors, I needed to automate this to run at a specific interval. Dockyard published a blog post titled Need an Elixir dependency to manage recurring jobs? Not so fast! where they cover recurring jobs using Elixir. I used this approach and felt it was a clean solution to the problem, without bringing in external modules.

A GenServer is used with two private functions, schedule_initital_job/0 and schedule_next_job/0. When the GenServer starts, the init/0 function calls schedule_initial_job/0, which calls self() passing :perform. There is a 5 second delay from the time schedule_initial_job/0 is called until the job is executed.

The GenServer matches :perform, calls the job needed to be ran, and then calls schedule_next_job/0. After 30 seconds, self() is called once more, passing :perform. This cycle continues indefinitely.

I also followed their configuration of the GenServer for disabling it within environments that you do not want it ran within (ex: testing, CI).

defmodule LakeEffect.Jobs.Server do
  @moduledoc false
  use GenServer
  alias LakeEffect.WeatherInfo

  def start_link() do
    GenServer.start_link(__MODULE__, [], name: :jobs_server)
  end

  def init(_) do
    if enabled?() do
      schedule_initial_job()
      {:ok, nil}
    else
      :ignore
    end
  end

  def handle_info(:perform, state) do
    WeatherInfo.collect()
    |> WeatherInfo.save()

    schedule_next_job()

    {:noreply, state}
  end

  defp schedule_initial_job() do
    Process.send_after(self(), :perform, 5_000)
  end

  defp schedule_next_job() do
    Process.send_after(self(), :perform, 30_000)
  end

  defp enabled?() do
    Application.get_env(:lake_effect, __MODULE__)[:enabled]
  end
end

 Sending the data off to a Thunder Snow server

As I haven’t started the Thunder Snow server yet, I decided to layout the client that will be used to send the data and simply print the data for now. A clients directory was created under lib/lake_effect to hold any possible clients, maybe I will send the data to Wunderground one day (probably not)!

├── clients
│   ├── thunder_snow
│   │   ├── impl.ex
│   │   └── server.ex
│   └── thunder_snow.ex

You will notice I am following the same pattern for laying out the GenServer as I did with the sensors. The GenServer is very basic and only calls out to send/1 which is in our Impl module.

defmodule LakeEffect.Clients.ThunderSnow.Server do
  @moduledoc false
  use GenServer
  alias LakeEffect.Clients.ThunderSnow.Impl

  def start_link() do
    GenServer.start_link(__MODULE__, [], name: :thunder_snow)
  end

  def init(_) do
    {:ok, %{}}
  end

  def handle_call({:send, data}, _from, state) do
    Impl.send(data)
    {:reply, :ok, state}
  end
end

The Impl module only contains send/1 which prints the weather information until a Thunder Snow server exists.

defmodule LakeEffect.Clients.ThunderSnow.Impl do
  @moduledoc false
  def send(%{wind_speed: ws, temperature: temp}) do
    IO.puts("Wind Speed: #{ws} m/s")
    IO.puts("Temperature: #{temp} °C")
  end
end

Lastly there is the API for the client, thunder_snow.ex. The module contains a single method send/1 that calls the GenServer with :send and the weather data.

defmodule LakeEffect.Clients.ThunderSnow do
  @spec send(LakeEffect.Jobs.Weather.weather_data()) :: term()
  def send(weather_data) do
    GenServer.call(:thunder_snow, {:send, weather_data})
  end
end

Part #5 will cover:

I may live stream some of the work for part #5 on Twitch.tv/frigidcode. Follow me on twitter for streaming announcements.

Edit: part #5 is live

As always, if you have any questions, please reach out to me on twitter or comment below. I would love to hear what you think of the project so far, and if you enjoy it, please share it!

 
1
Kudos
 
1
Kudos

Now read this

ElixirConf 2018 crunch time

Crunch time is here, ElixirConf is in 36 days! I started my presentation which is going to require a lot more time than previously imagined. My goal is to have the audience laugh a bit while learning how to build their own weather... Continue →