lib/grizzly/virtual_devices/thermostat.ex

defmodule Grizzly.VirtualDevices.Thermostat do
  @moduledoc """
  Implementation of a virtual device for a thermostat
  """

  use GenServer

  @behaviour Grizzly.VirtualDevices.Device

  alias Grizzly.ZWave.{Command, DeviceClass}

  alias Grizzly.ZWave.Commands.{
    BasicReport,
    SensorMultilevelReport,
    SensorMultilevelSupportedSensorReport,
    ThermostatFanModeReport,
    ThermostatFanStateReport,
    ThermostatModeReport,
    ThermostatSetpointReport
  }

  @impl Grizzly.VirtualDevices.Device
  def device_spec(_device_opts) do
    DeviceClass.thermostat_hvac()
  end

  @doc """
  Start a thermostat device
  """
  @spec start_link(keyword()) :: GenServer.on_start()
  def start_link(args) do
    GenServer.start_link(__MODULE__, args)
  end

  @impl Grizzly.VirtualDevices.Device
  def set_device_id(server, device_id) do
    GenServer.cast(server, {:set_device_id, device_id})
  end

  @impl Grizzly.VirtualDevices.Device
  def handle_command(command, device_opts) do
    server = Keyword.fetch!(device_opts, :server)
    GenServer.call(server, {:handle_command, command})
  end

  @impl GenServer
  def init(_opts) do
    state = %{
      setpoints: %{
        heating: 22.0,
        cooling: 26.0
      },
      fan_mode: :auto_low,
      fan_state: :off,
      temperature: 24.0,
      mode: :cooling,
      basic: :on,
      scale: :c,
      device_id: nil
    }

    {:ok, state}
  end

  @impl GenServer
  def handle_cast({:set_device_id, device_id}, state) do
    {:noreply, %{state | device_id: device_id}}
  end

  @impl GenServer
  def handle_call(:get_device_id, _from, state) do
    {:ok, state.device_id, state}
  end

  def handle_call(
        {:handle_command, %Command{name: :thermostat_setpoint_get} = command},
        _from,
        state
      ) do
    type = Command.param!(command, :type)

    case Map.get(state.setpoints, type) do
      nil ->
        {:reply, {:error, :timeout}, state}

      value ->
        response = ThermostatSetpointReport.new(type: type, value: value, scale: state.scale)
        {:reply, response, state}
    end
  end

  def handle_call(
        {:handle_command, %Command{name: :thermostat_setpoint_set} = command},
        _from,
        state
      ) do
    scale = Command.param!(command, :scale)
    type = Command.param!(command, :type)

    value =
      command
      |> Command.param!(:value)
      |> maybe_convert_value(scale, state)

    case Map.get(state.setpoints, type) do
      nil ->
        {:reply, :ok, state}

      _old_value ->
        new_setpoints = Map.put(state.setpoints, type, value)
        {:reply, :ok, %{state | setpoints: new_setpoints}}
    end
  end

  def handle_call({:handle_command, %Command{name: :thermostat_fan_mode_get}}, _from, state) do
    response = ThermostatFanModeReport.new(mode: state.fan_mode)

    {:reply, response, state}
  end

  def handle_call({:handle_command, %Command{name: :thermostat_fan_state_get}}, _from, state) do
    response = ThermostatFanStateReport.new(state: state.fan_state)

    {:reply, response, state}
  end

  def handle_call(
        {:handle_command, %Command{name: :thermostat_fan_mode_set} = command},
        _from,
        state
      ) do
    mode = Command.param!(command, :mode)

    {:reply, :ok, %{state | fan_mode: mode}}
  end

  def handle_call(
        {:handle_command, %Command{name: :thermostat_fan_state_set} = command},
        _from,
        state
      ) do
    fan_state = Command.param!(command, :state)

    {:reply, :ok, %{state | fan_state: fan_state}}
  end

  def handle_call({:handle_command, %Command{name: :sensor_multilevel_get}}, _from, state) do
    response =
      SensorMultilevelReport.new(sensor_type: :temperature, scale: 1, value: state.temperature)

    {:reply, response, state}
  end

  def handle_call({:handle_command, %Command{name: :thermostat_mode_get}}, _from, state) do
    response = ThermostatModeReport.new(mode: state.mode)
    {:reply, response, state}
  end

  def handle_call(
        {:handle_command, %Command{name: :thermostat_mode_set} = command},
        _from,
        state
      ) do
    new_mode = Command.param!(command, :mode)
    {:reply, :ok, %{state | mode: new_mode}}
  end

  def handle_call(
        {:handle_command, %Command{name: :sensor_multilevel_supported_sensor_get}},
        _from,
        state
      ) do
    response = SensorMultilevelSupportedSensorReport.new(sensor_types: [:temperature])
    {:reply, response, state}
  end

  def handle_call({:handle_command, %Command{name: :basic_get}}, _from, state) do
    response = BasicReport.new(value: state.basic)
    {:reply, response, state}
  end

  def handle_call({:handle_command, %Command{name: :basic_set} = command}, _from, state) do
    value = Command.param!(command, :value)
    {:reply, :ok, %{state | basic: value}}
  end

  def handle_call({:handle_command, _other}, state), do: {{:error, :timeout}, state}

  defp maybe_convert_value(value, scale, state) do
    if state.scale == scale do
      value
    else
      convert_value(value, scale, state.scale)
    end
  end

  defp convert_value(value, :c, :f) do
    celsius_to_fahrenheit(value)
  end

  defp convert_value(value, _fah, _cel) do
    fahrenheit_to_celsius(value)
  end

  defp celsius_to_fahrenheit(cel) do
    cel * 1.8 + 32
  end

  defp fahrenheit_to_celsius(fah) do
    (fah - 32) / 1.8
  end
end