Powered by AppSignal & Oban Pro

MQTT Absolute Humidity

mqtt-func.livemd

MQTT Absolute Humidity

Mix.install([
  {:kino, "~> 0.8.0"},
  {:tortoise, "~> 0.9"},
  {:jason, "~> 1.2"},
  {:math, "~> 0.7.0"}
])

Configuration

Which broker and prefix to work on:

kino_broker = Kino.Input.text("MQTT Broker:", default: "broker.hivemq.com")
kino_prefix = Kino.Input.text("MQTT Prefix:", default: "dk/sdu/iot/2023/myname")
Kino.Layout.grid([kino_broker, kino_prefix])
{broker, prefix} = {
  Kino.Input.read(kino_broker),
  Kino.Input.read(kino_prefix)
}

Absolute Humidity Node

First we start an MQTT client:

t = :os.system_time(:milli_seconds)
client_id = "sdu_iot_mqtt_livebook_ahum_#{t}"

Tortoise.Supervisor.start_child(
  client_id: client_id,
  handler: {Tortoise.Handler.Default, []},
  server: {Tortoise.Transport.Tcp, host: broker, port: 1883},
  subscriptions: []
)

Then, we define the node:

defmodule AbsoluteHumidity do
  use GenServer

  # interface

  def start_link({id, client_id, topic}) do
    otopic =
      topic
      |> Enum.slice(0..-2)
      |> Enum.join("/")
      |> String.replace("siggen", "ahum")
      |> (fn prefix -> prefix <> "/ahum" end).()

    state = [client: client_id, topic: otopic, temp: nil, rhum: nil]
    GenServer.start(__MODULE__, state, name: via_tuple(id))
  end

  def consume(pid, {_time, _value} = sample) when is_pid(pid) do
    GenServer.cast(pid, {:consume, sample})
  end

  def consume(client_id, topic, {_time, _value} = sample) do
    id = Enum.slice(topic, 0..-2)

    pid =
      case Registry.lookup(:ahum_registry, {__MODULE__, id}) do
        [] ->
          options = {id, client_id, topic}
          {:ok, pid} = AbsoluteHumidity.start_link(options)
          pid

        [{pid, _}] ->
          pid
      end

    GenServer.cast(pid, {:consume, topic, sample})
  end

  # callbacks

  @impl true
  def init(state) do
    {:ok, state}
  end

  @impl true
  def handle_cast({:consume, itopic, {time, value} = _sample}, state) do
    [client: client_id, topic: otopic, temp: temp, rhum: rhum] = state

    # update state
    {temp, rhum} =
      case List.last(itopic) do
        "temp" ->
          {value, rhum}

        "rhum" ->
          {temp, value}

        t ->
          IO.puts("Warning: Don't know what do to with last topic '#{t}'. Skipping ...")
          {temp, rhum}
      end

    # push out new ahum
    if temp != nil and rhum != nil do
      ahum = calc_abs_hum(temp, rhum)
      message = '{"time": #{time}, "value": #{ahum}}'
      :ok = Tortoise.publish(client_id, otopic, message, qos: 0)
    end

    state = [client: client_id, topic: otopic, temp: temp, rhum: rhum]
    {:noreply, state}
  end

  # helpers

  defp via_tuple(topic) do
    {:via, Registry, {:ahum_registry, {__MODULE__, topic}}}
  end

  # unit: g/m³
  defp calc_abs_hum(temp, rhum) do
    6.112 * Math.pow(Math.e(), 17.67 * temp / (temp + 243.5)) * rhum * 2.1674 / (273.15 + temp)
    #    temp * rhum
  end
end

Dispatcher Node

defmodule Dispatcher do
  use Tortoise.Handler

  def start_link(args) do
    GenServer.start(__MODULE__, args)
  end

  # callback functions

  @impl true
  def init(client: client_id) do
    {:ok, [client: client_id]}
  end

  def handle_message(topic, payload, [client: client_id] = state) do
    case payload |> Jason.decode() do
      {:ok, %{"time" => time, "value" => value}} ->
        AbsoluteHumidity.consume(client_id, topic, {time, value})

      _ ->
        nil
    end

    {:ok, state}
  end

  def child_spec(opts) do
    %{
      id: __MODULE__,
      start: {__MODULE__, :start_link, [opts]},
      type: :worker,
      restart: :permanent,
      shutdown: 500
    }
  end
end

Start registry (should be supervised, but we’ll skip that for brevity):

{:ok, registry_pid} = Registry.start_link(name: :ahum_registry, keys: :unique)

Setting up the subscription:

client_id = "sdu_iot_mqtt_livebook_ahum#{:os.system_time(:milli_seconds)}"
topic_pattern = prefix <> "/siggen/+/+"

{:ok, pid} =
  Tortoise.Connection.start_link(
    client_id: client_id,
    server: {Tortoise.Transport.Tcp, host: broker, port: 1883},
    handler: {
      Dispatcher,
      [client: client_id]
    },
    subscriptions: [{topic_pattern, 2}]
  )