Reading current solar PV output with Elixir

By: on March 25, 2020

I have a domestic solar PV installation and I would like to be able to programmatically read the current output, so that I can display it on a dashboard, perhaps together with other information such as a detailed weather forecast for the day and the expected sunset time.

The inverter in my installation records a variety of data and sends it to a web portal run by the manufacturer. I can log in and have a look at the current output and historical yields. Unfortunately, the web portal lags behind by up to two hours, which is not very helpful when I want to decide whether to turn on the washing machine.

The inverter has an internal Modbus TCP server, which gives direct, undelayed access to the current output and other information. This is somewhat exciting as parsing and assembling binary protocol messages is easy in Erlang/Elixir. Unfortunately, Modbus TCP is disabled by default and can only be enabled by those in possession of an installer password (or device-specific unlock key).

However, it turns out that the inverter also has a built-in web server, which is accessible in the local network and presents all relevant information. Programmatic access can be achieved by calling the various endpoints directly. I wrote an Elixir module to do that.

If you happen to have an inverter of the same make and model, you can query it for its current output in watts:

iex(1)> {:ok, session} = QSB36.user_login("192.168.1.90", "password")
{:ok, %QSB36.Session{host: "192.168.1.90", sid: "pgWW1dXgU2AgOdHD"}}

iex(2)> QSB36.current_watts(session)
{:ok, 1622}

iex(3)> QSB36.logout(session)
:ok

A number of other functions are supported as well: see module documentation.

Typically, you will want to poll the inverter for its current output once every few seconds, and (optionally) let other parts of your system know that there is a new value. In general, producers and consumers of sensor values may run at different rates, so it makes sense to decouple them. A new value once per second is unlikely to cause trouble unless you have to refresh a slow e-ink display or are connected to a sufficiently large number of sensors.

The following is a sketch of a module that polls the inverter and makes the values available to other processes:

defmodule Sensor do
  @interval 1000

  use GenServer

  def init([host, pw]) do
    Process.flag(:trap_exit, true) # just for demo...
    :ets.new(__MODULE__, [:named_table, :set, :protected, read_concurrency: true])
    schedule_poll()
    {:ok, {{host, pw}, nil, MapSet.new()}}
  end

  def handle_cast({:subscribe, pid}, {{host, pw}, session, listeners}), do:
    {:noreply, {{host, pw}, session, MapSet.put(listeners, pid)}}

  def handle_cast({:unsubscribe, pid}, {{host, pw}, session, listeners}), do:
    {:noreply, {{host, pw}, session, MapSet.delete(listeners, pid)}}

  def handle_info(:poll, {{host, pw}, session, listeners}) do
    with {:ok, session} <- ensure_logged_in(host, pw, session),
         {:ok, watts} <- QSB36.current_watts(session) do
      :ets.insert(__MODULE__, {:current_watts, watts})
      notify_listeners(listeners, {:current_watts, watts})
      schedule_poll()
      {:noreply, {{host, pw}, session, listeners}}
    else
      _ ->
        # might want to log this...
        ensure_logged_out(session)
        schedule_poll()
        {:noreply, {{host, pw}, nil, listeners}}
    end
  end

  def terminate(_reason, {_, session, _}), do:
    ensure_logged_out(session)

  defp schedule_poll(), do:
    Process.send_after(self(), :poll, @interval)

  defp ensure_logged_in(host, pw, nil), do:
    QSB36.user_login(host, pw)  

  defp ensure_logged_in(_host, _pw, session), do:
    {:ok, session}

  defp ensure_logged_out(nil), do:
    :ok

  defp ensure_logged_out(session), do:
    QSB36.logout(session)

  defp notify_listeners(listeners, event), do:
    Enum.each(listeners, fn listener -> send(listener, event) end)

  def start_link(args), do:
    GenServer.start_link(__MODULE__, args, name: __MODULE__)

  def current_watts() do
    with ref when ref != :undefined <- :ets.whereis(__MODULE__),
         [{_, watts}] <- :ets.lookup(__MODULE__, :current_watts) do
      watts
    else
      _ -> nil
    end
  end

  def subscribe(pid), do:
    GenServer.cast(__MODULE__, {:subscribe, pid})

  def unsubscribe(pid), do:
    GenServer.cast(__MODULE__, {:unsubscribe, pid})

end

And here is a small module that acts as a consumer:

defmodule Consumer do
  def loop() do
    receive do
      {:current_watts, w} -> 
        IO.puts ~s"#{w} watts"
        loop()
    end
  end
end

Demonstration:

iex(1)> {:ok, pid} = Sensor.start_link(["192.168.1.90", "password"])
{:ok, #PID}

iex(2)> consumer_pid = spawn(&Consumer.loop/0)
#PID

iex(3)> Sensor.subscribe(consumer_pid)
:ok
560 watts
564 watts
560 watts
563 watts
563 watts
563 watts

iex(4)> Sensor.unsubscribe(consumer_pid)
:ok

iex(5)> Sensor.current_watts()
564

iex(6)> Process.exit(pid, :normal)
true

Many refinements are possible. The next logical step is to build a dashboard UI and run it on a small device—there are plenty of interesting options to choose from.

Share

Leave a Reply

Your email address will not be published.

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

*