Powered by AppSignal & Oban Pro

Temp Humidity Monitor

temp_humidity_chart.livemd

Temp Humidity Monitor

Introduction

<- Back to index

defmodule StatFetcher do
  use GenServer

  # Public functions

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  def get(sensor) do
    GenServer.call(__MODULE__, {:get, sensor})
  end

  # Callback functions

  @impl true
  def init(opts) do
    {:ok, transport} = HTS221.Transport.init(HTS221.Transport.I2C, opts)
    {:ok, %HTS221.Calibration{} = calibration} = HTS221.read_calibration(transport)

    {:ok, {transport, calibration}}
  end

  @impl true
  def handle_call({:get, :temperature}, _from, {transport, calibration} = state) do
    {:ok, %HTS221.Temperature{} = temperature} = HTS221.read_temperature(transport)
    calibrated_temp = HTS221.calculate_temperature(temperature, calibration)

    {:reply, calibrated_temp, state}
  end

  def handle_call({:get, :humidity}, _from, {transport, calibration} = state) do
    {:ok, %HTS221.Humidity{} = humidity} = HTS221.read_humidity(transport)
    calibrated_humidity = HTS221.calculate_humidity(humidity, calibration)

    {:reply, calibrated_humidity, state}
  end
end
defmodule PngToMono do
  import Bitwise

  @threshold 128

  @doc """
  Convert a PNG to a packed 1bpp buffer sized exactly for the panel.

  Output is 1 = white, 0 = black, MSB = leftmost pixel.
  """
  def from_png(png, {cw, ch} = _canvas) when rem(cw, 8) == 0 do
    img = StbImage.read_binary!(png)
    {ih, iw, channels} = img.shape

    scale = min(cw / iw, ch / ih)
    w = iw |> Kernel.*(scale) |> round() |> clamp(1, cw)
    h = ih |> Kernel.*(scale) |> round() |> clamp(1, ch)

    img
    |> StbImage.resize(h, w)
    |> Map.fetch!(:data)
    |> to_gray(channels)
    |> center_on_white(w, h, cw, ch)
    |> pack(<<>>)
  end

  defp clamp(v, lo, hi), do: v |> max(lo) |> min(hi)

  # --- greyscale in one pass, specialised on channel count ---

  defp to_gray(data, 1), do: data

  defp to_gray(data, 2),
    do: for(<<v, a <- data>>, into: <<>>, do: <<over_white(v, a)>>)

  defp to_gray(data, 3),
    do: for(<<r, g, b <- data>>, into: <<>>, do: <<luma(r, g, b)>>)

  defp to_gray(data, 4),
    do: for(<<r, g, b, a <- data>>, into: <<>>, do: <<over_white(luma(r, g, b), a)>>)

  # Rec.601 in 8.8 fixed point: 77 + 151 + 28 == 256
  defp luma(r, g, b), do: (r * 77 + g * 151 + b * 28) >>> 8

  defp over_white(v, 255), do: v
  defp over_white(v, a), do: (v * a + 255 * (255 - a) + 128) >>> 8

  # --- centre on a white canvas of exactly cw x ch ---

  defp center_on_white(gray, w, h, cw, ch) do
    left = div(cw - w, 2)
    top = div(ch - h, 2)

    lpad = :binary.copy(<<255>>, left)
    rpad = :binary.copy(<<255>>, cw - w - left)
    blank = :binary.copy(<<255>>, cw)

    body =
      for <<row::binary-size(^w) <- gray>>, into: <<>> do
        <<lpad::binary, row::binary, rpad::binary>>
      end

    :binary.copy(blank, top) <> body <> :binary.copy(blank, ch - h - top)
  end

  # --- 8 pixels -> 1 byte, MSB = leftmost ---

  defp pack(<<>>, acc), do: acc

  defp pack(<<a, b, c, d, e, f, g, h, rest::binary>>, acc) do
    byte =
      <<bit(a)::1, bit(b)::1, bit(c)::1, bit(d)::1, bit(e)::1, bit(f)::1, bit(g)::1, bit(h)::1>>

    pack(rest, <<acc::binary, byte::binary>>)
  end

  defp bit(v) when v >= @threshold, do: 1
  defp bit(_), do: 0
end
defmodule VegaLiteEInkRenderer do
  def render(time_series_data) do
    encoded_vega_lite_chart =
      [
        width: 600,
        height: 400,
        title: "Nerves Starter Kit Weather Monitor"
      ]
      |> VegaLite.new()
      |> VegaLite.config(
        title: [font_size: 24],
        axis: [
          title_font_size: 20,
          label_font_size: 18,
          grid: false
        ],
        legend: [
          symbol_type: "stroke",
          symbol_stroke_width: 4,
          symbol_size: 300,
          title_font_size: 18,
          label_font_size: 16
        ]
      )
      |> VegaLite.data_from_values(time_series_data)
      |> VegaLite.mark(:line, stroke_width: 4)
      |> VegaLite.encode_field(:x, "timestamp",
        type: :temporal,
        title: "Time",
        axis: [
          format: "%H:%M",
          title: "Time",
          label_angle: -45
        ]
      )
      |> VegaLite.encode_field(:y, "value",
        type: :quantitative,
        title: "Value"
      )
      |> VegaLite.encode_field(:stroke_dash, "type", type: :nominal)
      |> VegaLite.to_spec()
      |> JSON.encode!()
      |> :zlib.compress()
      |> Base.url_encode64()

    "https://kroki.io/vegalite/png/#{encoded_vega_lite_chart}"
    |> Req.get!()
    |> Map.fetch!(:body)
    |> PngToMono.from_png({648, 480})
  end
end
defmodule StatPlotter do
  use GenServer

  @default_eink_opts [
    dc_pin: "EPD_DC",
    reset_pin: "EPD_RESET",
    busy_pin: "EPD_BUSY",
    spi_device: "spidev0.0"
  ]

  # Public functions

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  # Callback functions

  @impl true
  def init(opts) do
    {poll_interval, rest_opts} = Keyword.pop(opts, :poll_interval, 10_000)

    {measurement_ring_buffer_max_length, eink_opts} =
      Keyword.pop(rest_opts, :measurement_ring_buffer_max_length, 20)

    eink_opts = Keyword.merge(@default_eink_opts, eink_opts)
    {:ok, eink} = EInk.new(EInk.Driver.UC8179, eink_opts)

    state = %{
      poll_interval: poll_interval,
      eink: eink,
      measurement_ring_buffer: :queue.new(),
      measurement_ring_buffer_length: 0,
      measurement_ring_buffer_max_length: measurement_ring_buffer_max_length
    }

    {:ok, state, {:continue, :schedule_next_run}}
  end

  @impl true
  def handle_continue(:schedule_next_run, %{poll_interval: poll_interval} = state) do
    Process.send_after(self(), :perform_cron_work, poll_interval)

    {:noreply, state}
  end

  @impl true
  def handle_info(:perform_cron_work, state) do
    timestamp = NaiveDateTime.utc_now()

    measurements =
      Enum.map([:temperature, :humidity], fn sensor ->
        value =
          sensor
          |> StatFetcher.get()
          |> Decimal.from_float()
          |> Decimal.round(2)

        %{
          type: sensor |> Atom.to_string() |> String.capitalize(),
          value: value,
          timestamp: timestamp
        }
      end)

    {measurement_ring_buffer_length, updated_measurement_ring_buffer} =
      if state.measurement_ring_buffer_length >= state.measurement_ring_buffer_max_length do
        {_value, queue} = :queue.out(state.measurement_ring_buffer)

        {state.measurement_ring_buffer_max_length, :queue.in(measurements, queue)}
      else
        {state.measurement_ring_buffer_length + 1,
         :queue.in(measurements, state.measurement_ring_buffer)}
      end

    updated_state =
      state
      |> Map.put(:measurement_ring_buffer_length, measurement_ring_buffer_length)
      |> Map.put(:measurement_ring_buffer, updated_measurement_ring_buffer)

    flattened_measurements =
      updated_measurement_ring_buffer
      |> :queue.to_list()
      |> List.flatten()

    image_bit_map = VegaLiteEInkRenderer.render(flattened_measurements)
    EInk.clear(updated_state.eink, :white)
    EInk.draw(state.eink, image_bit_map)

    {:noreply, updated_state, {:continue, :schedule_next_run}}
  end
end
defmodule TempMonitor do
  use Supervisor

  @bus_opts [bus_name: "i2c-0"]

  def start_link(opts) do
    Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @impl true
  def init(_opts) do
    children = [
      {HTS221.Server, transport: {HTS221.Transport.I2C, @bus_opts}},
      {StatFetcher, @bus_opts},
      StatPlotter
    ]

    Supervisor.init(children, strategy: :one_for_one)
  end
end
{:ok, pid} = TempMonitor.start_link([])
Kino.Process.render_sup_tree(pid)