From 211e7a1a8d80ca085d71d6fbc3eae5d15d7ec182 Mon Sep 17 00:00:00 2001 From: Giovanni Visciano Date: Fri, 8 Sep 2017 09:10:54 +0200 Subject: [PATCH] singletone input device support --- .travis.yml | 6 +- README.md | 27 +++++++-- lib/nerves_dht.ex | 121 ++++++++++++++++++++++++--------------- lib/nerves_dht/device.ex | 76 ++++++++++++++++++++++++ lib/nerves_dht/driver.ex | 41 +++++++++++++ mix.exs | 8 ++- test/device_test.exs | 52 +++++++++++++++++ test/dht_test.exs | 106 ++++++++++++++++++++++++++++++++++ test/nerves_dht_test.exs | 89 ---------------------------- test/test_helper.exs | 4 +- test/utils.ex | 17 ++++++ 11 files changed, 400 insertions(+), 147 deletions(-) create mode 100644 lib/nerves_dht/device.ex create mode 100644 lib/nerves_dht/driver.ex create mode 100644 test/device_test.exs create mode 100644 test/dht_test.exs delete mode 100644 test/nerves_dht_test.exs create mode 100644 test/utils.ex diff --git a/.travis.yml b/.travis.yml index 9071284..27203bd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,5 @@ env: matrix: include: - - otp_release: 19.0 - elixir: 1.4 - - otp_release: 20.0 - elixir: 1.5.1 + - otp_release: 20 + elixir: 1.5 diff --git a/README.md b/README.md index 7cc7190..ef2f4bd 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,13 @@ Elixir library to read the DHT series of humidity and temperature sensors on a R The library is supposed to be included in a [nerves project](http://nerves-project.org/). If you want to build your project directly on a Raspberry (not in a crosscompiling nerves project) -just export MIX_TARGET environment variable to you mix build. -Valid values for MIX_TARGET are rpi | rp2 | rp3. +just export `MIX_TARGET` environment variable to you mix build. +Valid values for `MIX_TARGET` are `rpi`, `rp2`, `rp3`. * Supported sensors: DHT11, DHT22, AM2302 -* Supported boards: Raspberry 1 / 2 / 3 +* Supported boards: Raspberry 1, 2, 3 -Note: the library has no external dependencies and use a C executable to read the sensors data. +**Note**: the library has no external dependencies and use a C executable to read the sensors data. ## Installation @@ -21,7 +21,7 @@ The package can be installed by adding `nerves_dht` to your list of dependencies ```elixir def deps do [ - {:nerves_dht, git: "https://github.com/visciang/nerves_dht.git", tag: "1.0.0"} + {:nerves_dht, git: "https://github.com/visciang/nerves_dht.git", tag: "1.1.0"} ] end ``` @@ -35,3 +35,20 @@ iex> NervesDHT.read(:am2302, 17) iex> NervesDHT.stream(:am2302, 17) |> Enum.take(2) [{:ok, 55.1, 24.719}, {:ok, 55.12, 24.9}] ``` + +If you plan to read concurrently from the sensor, add `NervesDHT` to your application supervisor tree: + +```elixir +children = [ + {NervesDHT, [name: :my_sensor, sensor: :am2302, pin: 17]}, + ... +] +Supervisor.start_link(children, strategy: :one_for_one) +``` + +and read with: + +```elixir +NervesDHT.device_read(:my_sensor) +NervesDHT.device_stream(:my_sensor) +``` diff --git a/lib/nerves_dht.ex b/lib/nerves_dht.ex index 5ab3759..57dbde9 100644 --- a/lib/nerves_dht.ex +++ b/lib/nerves_dht.ex @@ -1,8 +1,18 @@ defmodule NervesDHT do @moduledoc """ Elixir library to read the DHT series of humidity and temperature sensors on a Raspberry Pi. + + The library expose direct access to the sensors via `read/4` and `stream/3`. + + If your application has multiple processes reading the same sensor concurrently you should + add `NervesDHT` under your supervisor (see `child_spec/1`). + It ensures that only one read operation at the time is executed. + In case of multiple sources asking for a read while the operation is in progress, they will + get the result of the ongoing read. """ + @typedoc "Device identifier" + @type device_id :: atom @typedoc "Sensor model" @type sensor :: :dht11 | :dht22 | :am2302 @typedoc "GPIO pin (using BCM numbering)" @@ -15,15 +25,15 @@ defmodule NervesDHT do @type interval :: non_neg_integer @typedoc "Error reason" @type reason :: :timeout | :checksum | :argument | :gpio - @typedoc "Successful reading humidity and temperature values" + @typedoc "Reading result" @type result :: {:ok, humidity :: float(), temperature :: float()} | {:error, reason} - # executable name injection (tests use "dht_exe.sh" fake) - @dht_exe Application.get_env(:nerves_dht, :dht_exe, "dht") + @retries 3 + @delay 2000 @doc """ Read DHT sensor values. - + Read from the specified `sensor` type (DHT11, DHT22, or AM2302) on specified `pin` and return humidity (as a floating point value in percent) and temperature (as a floating point value in Celsius) as @@ -44,10 +54,10 @@ defmodule NervesDHT do """ @spec read(sensor, pin, retries, delay) :: result - def read(sensor, pin, retries \\ 3, delay \\ 2000) + def read(sensor, pin, retries \\ @retries, delay \\ @delay) def read(sensor, pin, retries, delay) do - result = dht_read(sensor, pin) + result = NervesDHT.Driver.dht_read(sensor, pin) case result do {:ok, humidity, temperature} -> @@ -60,10 +70,9 @@ defmodule NervesDHT do @doc """ Return a Stream of sensor readings. - The reading `interval` defaults to 2 seconds. - Interval is the wait period beetwen two consecutive read attempts. - Since the device takes some X time to transmit the reading, the Stream - will push data with a `period >= X+interval`. + `interval` is the wait period beetwen two consecutive read attempts and + defaults to 2 seconds. Since the device takes some `x` time to transmit + the reading, the Stream will push data with a `period >= (interval + x)`. ## Examples @@ -72,11 +81,66 @@ defmodule NervesDHT do """ @spec stream(sensor, pin, interval) :: Enumerable.t - def stream(sensor, pin, interval \\ 2000) do + def stream(sensor, pin, interval \\ @delay) do Stream.interval(interval) |> Stream.map(fn(_) -> read(sensor, pin, 0, 0) end) end + @doc """ + Return the child specification to put the a named device under your supervisor tree. + The device can be used to read concurrently from the sensor. + + Add to you supervisor: + + ```elixir + children = [ + {NervesDHT, [name: :my_sensor, sensor: :am2302, pin: 17]}, + ... + ] + Supervisor.start_link(children, strategy: :one_for_one) + ``` + + Read from the named `:my_sensor` device: + + ```elixir + NervesDHT.device_read(:my_sensor) + ``` + """ + @spec child_spec([name: device_id, sensor: sensor, pin: pin]) :: Supervisor.child_spec + def child_spec([name: name, sensor: sensor, pin: pin]) do + fun = fn -> __MODULE__.read(sensor, pin) end + timeout = @retries * @delay + %{ + id: "#{__MODULE__}_#{sensor}_#{pin}", + start: {NervesDHT.Device, :start_link, [name, fun, timeout]}, + restart: :permanent, + shutdown: 5000, + type: :worker + } + end + + @doc """ + Read DHT sensor values from the named device `device_id`. + The underlying read/4 operation will apply the default `retries`, `delay` strategy. + + See `child_spec/1`, `read/4`. + """ + @spec device_read(device_id) :: result + def device_read(device_id) do + NervesDHT.Device.read(device_id, (@retries + 1) * @delay) + end + + @doc """ + Return a Stream of sensor readings from the named device `device_id`. + + See `child_spec/1`, stream/3. + """ + @spec device_stream(device_id, interval) :: Enumerable.t + def device_stream(device_id, interval \\ @delay) do + Stream.interval(interval) + |> Stream.map(fn(_) -> device_read(device_id) end) + end + defp read_again(_sensor, _pin, retries, _delay, error) when retries <= 0 do {:error, error} end @@ -90,39 +154,4 @@ defmodule NervesDHT do {:error, error} end end - - defp dht_read(sensor, pin) do - cmd = Application.app_dir(:nerves_dht, Path.join("priv", @dht_exe)) - args = dht_cmd_args(sensor, pin) - - {result, exit_status} = System.cmd(cmd, args) - - if exit_status == 0 do - [humidity_str, temperature_str] = String.split(result) - - {humidity, ""} = Float.parse(humidity_str) - {temperature, ""} = Float.parse(temperature_str) - - {:ok, humidity, temperature} - else - {:error, dht_error_code_to_reason(exit_status)} - end - end - - defp dht_cmd_args(sensor, pin) do - [ - case sensor do - :dht11 -> "11" - :dht22 -> "22" - :am2302 -> "2302" - end, - to_string(pin) - ] - end - - # -1, -2, -3, -4 - defp dht_error_code_to_reason(255), do: :timeout - defp dht_error_code_to_reason(254), do: :checksum - defp dht_error_code_to_reason(253), do: :argument - defp dht_error_code_to_reason(252), do: :gpio end diff --git a/lib/nerves_dht/device.ex b/lib/nerves_dht/device.ex new file mode 100644 index 0000000..4bc7227 --- /dev/null +++ b/lib/nerves_dht/device.ex @@ -0,0 +1,76 @@ +defmodule NervesDHT.Device do + @moduledoc false + + defmodule Runner do + @moduledoc false + + use GenServer + + defmodule State do + @moduledoc false + + defstruct [:father, :fun, :timeout] + end + + def start_link(father, fun, timeout) do + state = %State{father: father, fun: fun, timeout: timeout} + GenServer.start_link(__MODULE__, state) + end + + @impl GenServer + def handle_cast(:read, %State{father: father, fun: fun, timeout: timeout}=state) do + task = Task.async(fun) + case Task.yield(task, timeout) do + {:ok, result} -> + GenServer.cast(father, {:result, result}) + nil -> + Task.shutdown(task) + GenServer.cast(father, {:result, {:error, :timeout}}) + end + {:noreply, state} + end + end + + use GenServer + + defmodule State do + @moduledoc false + defstruct [:runner, :callers_queue] + end + + def start_link(name, fun, timeout \\ 4000) do + GenServer.start_link(__MODULE__, [fun, timeout], name: name) + end + + def read(ref, timeout \\ 5000) do + GenServer.call(ref, :read, timeout) + end + + @impl GenServer + def init([fun, timeout]) do + {:ok, runner} = Runner.start_link(self(), fun, timeout) + {:ok, %State{runner: runner, callers_queue: []}} + end + + @impl GenServer + def handle_call(:read, from, %State{runner: runner, callers_queue: []}=state) do + GenServer.cast(runner, :read) + {:noreply, %State{state | callers_queue: [from]}} + end + + @impl GenServer + def handle_call(:read, from, %State{callers_queue: callers_queue}=state) do + {:noreply, %State{state | callers_queue: [from | callers_queue]}} + end + + @impl GenServer + def handle_cast({:result, result}, %State{callers_queue: callers_queue}=state) + when callers_queue != [] do + notify(callers_queue, result) + {:noreply, %State{state | callers_queue: []}} + end + + defp notify(callers_queue, reply) do + Enum.each(callers_queue, &(GenServer.reply(&1, reply))) + end +end diff --git a/lib/nerves_dht/driver.ex b/lib/nerves_dht/driver.ex new file mode 100644 index 0000000..a9c6637 --- /dev/null +++ b/lib/nerves_dht/driver.ex @@ -0,0 +1,41 @@ +defmodule NervesDHT.Driver do + @moduledoc false + + # executable name injection (tests use "dht_exe.sh" fake) + @dht_exe Application.get_env(:nerves_dht, :dht_exe, "dht") + + def dht_read(sensor, pin) do + cmd = Application.app_dir(:nerves_dht, Path.join("priv", @dht_exe)) + args = dht_cmd_args(sensor, pin) + + {result, exit_status} = System.cmd(cmd, args) + + if exit_status == 0 do + [humidity_str, temperature_str] = String.split(result) + + {humidity, ""} = Float.parse(humidity_str) + {temperature, ""} = Float.parse(temperature_str) + + {:ok, humidity, temperature} + else + {:error, dht_error_code_to_reason(exit_status)} + end + end + + defp dht_cmd_args(sensor, pin) do + [ + case sensor do + :dht11 -> "11" + :dht22 -> "22" + :am2302 -> "2302" + end, + to_string(pin) + ] + end + + # -1, -2, -3, -4 + defp dht_error_code_to_reason(255), do: :timeout + defp dht_error_code_to_reason(254), do: :checksum + defp dht_error_code_to_reason(253), do: :argument + defp dht_error_code_to_reason(252), do: :gpio +end \ No newline at end of file diff --git a/mix.exs b/mix.exs index a8e3ac2..d3aa3ac 100644 --- a/mix.exs +++ b/mix.exs @@ -5,7 +5,7 @@ defmodule NervesDHT.Mixfile do [ app: :nerves_dht, version: "1.0.0", - elixir: "~> 1.4", + elixir: "~> 1.5", start_permanent: Mix.env == :prod, compilers: [:elixir_make] ++ Mix.compilers, deps: deps(), @@ -14,7 +14,11 @@ defmodule NervesDHT.Mixfile do end def application do - [] + if Mix.env == :test do + [extra_applications: [:logger]] + else + [] + end end defp deps do diff --git a/test/device_test.exs b/test/device_test.exs new file mode 100644 index 0000000..58d1626 --- /dev/null +++ b/test/device_test.exs @@ -0,0 +1,52 @@ +defmodule Test.Device do + use ExUnit.Case, async: false + + describe "NervesDHT.Device.read/2" do + test "read ok" do + device = :test_ok + {:ok, _pid} = NervesDHT.Device.start_link(device, fn -> :ok! end) + + assert :ok! = NervesDHT.Device.read(device) + end + + test "read timeout" do + device = :test_timeout + timeout = 200 + {:ok, _pid} = NervesDHT.Device.start_link(device, fn -> Process.sleep(timeout * 2) end, timeout) + + assert {:error, :timeout} = NervesDHT.Device.read(device) + end + + @tag :capture_log + test "read crash" do + device = :test_crash + {:ok, pid} = NervesDHT.Device.start_link(device, fn -> raise "crash" end) + Process.unlink(pid) + + catch_exit do + NervesDHT.Device.read(device) + end + + refute Process.alive?(pid) + end + + test "read parallel" do + device = :test_parallel + {:ok, _pid} = NervesDHT.Device.start_link(device, + fn -> + Process.sleep(400) # :-( + :rand.normal() + end) + + task_1 = Task.async(fn -> NervesDHT.Device.read(device) end) + task_2 = Task.async(fn -> NervesDHT.Device.read(device) end) + x = Task.await(task_1) + y = Task.await(task_2) + assert x == y + + task_3 = Task.async(fn -> NervesDHT.Device.read(device) end) + z = Task.await(task_3) + assert z != x + end + end +end diff --git a/test/dht_test.exs b/test/dht_test.exs new file mode 100644 index 0000000..8992c29 --- /dev/null +++ b/test/dht_test.exs @@ -0,0 +1,106 @@ +defmodule Test.DirectAccess do + use ExUnit.Case, async: false + + alias NervesDHT, as: DHT + alias Test.Utils + + describe "NervesDHT.read/4" do + test "read ok" do + Utils.set_sensor_response("55.1", "24.719", "0") + assert {:ok, 55.1, 24.719} = DHT.read(:dht11, 17) + end + + test "read errors code" do + Enum.each( + [{"252", :gpio}, {"253", :argument}, {"254", :checksum}, {"255", :timeout}], + fn ({exit_status, exit_reason}) -> + Utils.set_sensor_response("", "", exit_status) + assert {:error, ^exit_reason} = DHT.read(:dht11, 17, 0) + end + ) + end + + test "read retry bad `retries` values" do + Utils.set_sensor_response("", "", "255") + assert {:error, :timeout} = DHT.read(:dht11, 17, -1) + Utils.check_call_counter(1) + end + + test "read retry on transient errors" do + retries = 2 + interval = 0 + + Utils.set_sensor_response("", "", "255") + assert {:error, :timeout} = DHT.read(:dht11, 17, retries, interval) + Utils.check_call_counter(retries + 1) + + Utils.set_sensor_response("", "", "254") + assert {:error, :checksum} = DHT.read(:dht11, 17, retries, interval) + Utils.check_call_counter(retries + 1) + end + + test "read retry interval" do + interval = 200 + retries = 3 + + Utils.set_sensor_response("", "", "255") + start_time = System.monotonic_time(:milliseconds) + assert {:error, :timeout} = DHT.read(:dht11, 17, retries, interval) + end_time = System.monotonic_time(:milliseconds) + + elapsed_time = end_time - start_time + + assert elapsed_time >= (interval * (retries)) + end + end + + describe "NervesDHT.stream/3" do + test "read stream" do + interval = 0 + + Utils.set_sensor_response("55.1", "24.719", "0") + assert [{:ok, 55.1, 24.719}] = DHT.stream(:am2302, 17, interval) |> Enum.take(1) + end + + test "read stream interval" do + interval = 200 + take = 3 + + Utils.set_sensor_response("55.1", "24.719", "0") + expected = List.duplicate({:ok, 55.1, 24.719}, take) + start_time = System.monotonic_time(:milliseconds) + assert ^expected = DHT.stream(:am2302, 17, interval) |> Enum.take(take) + end_time = System.monotonic_time(:milliseconds) + + elapsed_time = end_time - start_time + + assert elapsed_time >= (interval * (take)) + end + end +end + +defmodule Test.Supervised do + use ExUnit.Case, async: false + + alias NervesDHT, as: DHT + alias Test.Utils + + setup do + opts = [name: :test_sensor, sensor: :am2302, pin: 17] + {:ok, _pid} = start_supervised({NervesDHT, opts}) + + :ok + end + + test "device_read" do + Utils.set_sensor_response("55.1", "24.719", "0") + assert {:ok, 55.1, 24.719} = DHT.device_read(:test_sensor) + end + + test "device_stream" do + interval = 0 + + Utils.set_sensor_response("40.1", "20.1", "0") + assert [{:ok, 40.1, 20.1}] = DHT.stream(:am2302, 17, interval) |> Enum.take(1) + end +end diff --git a/test/nerves_dht_test.exs b/test/nerves_dht_test.exs deleted file mode 100644 index a345ef9..0000000 --- a/test/nerves_dht_test.exs +++ /dev/null @@ -1,89 +0,0 @@ -defmodule NervesDHT.Test do - use ExUnit.Case, async: false - - alias NervesDHT, as: DHT - - @dht_call_count "/tmp/dht_call_count" - - test "read ok" do - set_sensor_response("55.1", "24.719", "0") - assert {:ok, 55.1, 24.719} = DHT.read(:dht11, 17) - end - - test "read errors code" do - Enum.each( - [{"252", :gpio}, {"253", :argument}, {"254", :checksum}, {"255", :timeout}], - fn ({exit_status, exit_reason}) -> - set_sensor_response("", "", exit_status) - assert {:error, ^exit_reason} = DHT.read(:dht11, 17, 0) - end - ) - end - - test "read retry bad `retries` values" do - set_sensor_response("", "", "255") - assert {:error, :timeout} = DHT.read(:dht11, 17, -1) - check_call_counter(1) - end - - test "read retry on transient errors" do - retries = 2 - interval = 0 - - set_sensor_response("", "", "255") - assert {:error, :timeout} = DHT.read(:dht11, 17, retries, interval) - check_call_counter(retries + 1) - - set_sensor_response("", "", "254") - assert {:error, :checksum} = DHT.read(:dht11, 17, retries, interval) - check_call_counter(retries + 1) - end - - test "read retry interval" do - interval = 200 - retries = 3 - - set_sensor_response("", "", "255") - start_time = System.monotonic_time(:milliseconds) - assert {:error, :timeout} = DHT.read(:dht11, 17, retries, interval) - end_time = System.monotonic_time(:milliseconds) - - elapsed_time = end_time - start_time - - assert elapsed_time >= (interval * (retries)) - end - - test "read stream" do - interval = 0 - - set_sensor_response("55.1", "24.719", "0") - assert [{:ok, 55.1, 24.719}] = DHT.stream(:am2302, 17, interval) |> Enum.take(1) - end - - test "read stream interval" do - interval = 200 - take = 3 - - set_sensor_response("55.1", "24.719", "0") - expected = List.duplicate({:ok, 55.1, 24.719}, take) - start_time = System.monotonic_time(:milliseconds) - assert ^expected = DHT.stream(:am2302, 17, interval) |> Enum.take(take) - end_time = System.monotonic_time(:milliseconds) - - elapsed_time = end_time - start_time - - assert elapsed_time >= (interval * (take)) - end - - defp set_sensor_response(humidity, temperature, exit_status) do - System.put_env("DHT_HUMIDITY", humidity) - System.put_env("DHT_TEMPERATURE", temperature) - System.put_env("DHT_EXIT", exit_status) - File.rm(@dht_call_count) - end - - defp check_call_counter(count) do - data = File.read!(@dht_call_count) - assert ^data = String.duplicate(".", count) - end -end diff --git a/test/test_helper.exs b/test/test_helper.exs index 4b8b246..bf3efee 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,3 @@ -ExUnit.start +ExUnit.start() + +Code.require_file "utils.ex", __DIR__ diff --git a/test/utils.ex b/test/utils.ex new file mode 100644 index 0000000..80f55af --- /dev/null +++ b/test/utils.ex @@ -0,0 +1,17 @@ +defmodule Test.Utils do + require ExUnit.Assertions + + @dht_call_count "/tmp/dht_call_count" + + def set_sensor_response(humidity, temperature, exit_status) do + System.put_env("DHT_HUMIDITY", humidity) + System.put_env("DHT_TEMPERATURE", temperature) + System.put_env("DHT_EXIT", exit_status) + File.rm(@dht_call_count) + end + + def check_call_counter(count) do + data = File.read!(@dht_call_count) + ExUnit.Assertions.assert ^data = String.duplicate(".", count) + end +end