lib/grizzly/zwave/commands/door_lock_operation_report.ex

defmodule Grizzly.ZWave.Commands.DoorLockOperationReport do
  @moduledoc """
  OperationReport is used to advertise the status of a door lock

  This is response to the `Grizzly.ZWave.Commands.OperationGet`
  command.

  Params:

    * `:mode` - the door operating lock mode (required)
    * `:outside_handles_mode` - a map of the outside door handles and if they
      can or cannot open the door locally (optional)
    * `:inside_handles_mode` - a map of the inside door handles and if they can
      or cannot open the door locally (optional)
    * `:latch_position` - the position of the latch (optional)
    * `:bolt_position` - the position of the bolt (optional)
    * `:door_state` - the state of the door being open or closed (optional)
    * `:timeout_minutes` - how long the door has been unlocked (required)
    * `:timeout_seconds` - how long the door has been unlocked (required)
    * `:target_mode` - the target mode of an ongoing transition or of the most recent transition (optional - v4)
    * `duration` - the estimated remaining time before the target mode is realized (optional - v4)
  """

  @behaviour Grizzly.ZWave.Command

  alias Grizzly.ZWave.{Command, DecodeError}
  alias Grizzly.ZWave.CommandClasses.DoorLock

  @typedoc """
  These modes tell if the handle can open the door locally or not.

  The door lock does not have to report all or any of these, so the default is
  to set them all to disabled if they are not specified when building the
  command.
  """
  @type handles_mode :: %{non_neg_integer() => :enabled | :disabled}

  @typedoc """
  This param only matters if the door lock says it supports this door
  component in the CapabilitiesReport.

  If it isn't support the node receiving this report can ignore.

  For defaults, if this param isn't provided during when calling `new/1`, we
  0 this field out by setting it to :disabled
  """
  @type latch_position :: :open | :closed

  @typedoc """
  This param only matters if the door lock says it supports this door
  component in the CapabilitiesReport.

  If it isn't support the node receiving this report can ignore.

  For defaults, if this param isn't provided during when calling `new/1`, we
  0 this field out by setting it to :disabled
  """
  @type bolt_position :: :locked | :unlocked

  @typedoc """
  This param only matters if the door lock says it supports this door
  component in the CapabilitiesReport.

  If it isn't support the node receiving this report can ignore.

  For defaults, if this param isn't provided during when calling `new/1`, we
  0 this field out by setting it to :disabled
  """
  @type door_state :: :open | :closed

  @type timeout_minutes :: 0x00..0xFD | :undefined

  @type timeout_seconds :: 0x00..0x3B | :undefined

  @type param ::
          {:mode, DoorLock.mode()}
          | {:outside_handles_mode, handles_mode()}
          | {:inside_handles_mode, handles_mode()}
          | {:latch_position, latch_position()}
          | {:bolt_position, bolt_position()}
          | {:door_state, door_state()}
          | {:timeout_minutes, timeout_minutes()}
          | {:timeout_seconds, timeout_seconds()}
          | {:target_mode, DoorLock.mode()}
          | {:duration, :unknown | non_neg_integer()}

  @impl true
  @spec new([param()]) :: {:ok, Command.t()}
  def new(params) do
    command = %Command{
      name: :door_lock_operation_report,
      command_byte: 0x03,
      command_class: DoorLock,
      params: params_with_defaults(params),
      impl: __MODULE__
    }

    {:ok, command}
  end

  @impl true
  @spec encode_params(Command.t()) :: binary()
  def encode_params(command) do
    mode = Command.param!(command, :mode)
    outside_door_handles = Command.param!(command, :outside_handles_mode)
    inside_door_handles = Command.param!(command, :inside_handles_mode)
    latch_position = Command.param!(command, :latch_position)
    bolt_position = Command.param!(command, :bolt_position)
    door_state = Command.param!(command, :door_state)
    timeout_minutes = Command.param!(command, :timeout_minutes)
    timeout_seconds = Command.param!(command, :timeout_seconds)
    target_mode = Command.param(command, :target_mode)
    outside_handles_byte = door_handles_modes_to_byte(outside_door_handles)
    inside_handles_byte = door_handles_modes_to_byte(inside_door_handles)
    door_condition_byte = door_condition_to_byte(latch_position, bolt_position, door_state)
    timeout_minutes_byte = timeout_minutes_to_byte(timeout_minutes)
    timeout_seconds_byte = timeout_seconds_to_byte(timeout_seconds)

    <<handles_byte>> = <<outside_handles_byte::size(4), inside_handles_byte::size(4)>>

    if target_mode == nil do
      <<DoorLock.mode_to_byte(mode), handles_byte, door_condition_byte, timeout_minutes_byte,
        timeout_seconds_byte>>
    else
      # version 4
      duration = Command.param!(command, :duration)
      target_mode_byte = DoorLock.mode_to_byte(target_mode)
      duration_byte = duration_to_byte(duration)

      <<DoorLock.mode_to_byte(mode), handles_byte, door_condition_byte, timeout_minutes_byte,
        timeout_seconds_byte, target_mode_byte, duration_byte>>
    end
  end

  @impl true
  @spec decode_params(binary()) :: {:ok, [param()]} | {:error, DecodeError.t()}
  def decode_params(
        <<mode_byte, outside_handles_int::size(4), inside_handles_int::size(4),
          door_condition_byte, timeout_minutes, timeout_seconds>>
      ) do
    outside_handles = door_handles_modes_from_byte(outside_handles_int)
    inside_handles = door_handles_modes_from_byte(inside_handles_int)
    latch_position = latch_position_from_byte(door_condition_byte)
    bolt_position = bolt_position_from_byte(door_condition_byte)
    door_state = door_state_from_byte(door_condition_byte)

    with {:ok, mode} <- DoorLock.mode_from_byte(mode_byte),
         {:ok, timeout_minutes} <- timeout_minutes_from_byte(timeout_minutes),
         {:ok, timeout_seconds} <- timeout_seconds_from_byte(timeout_seconds) do
      {:ok,
       [
         mode: mode,
         outside_handles_mode: outside_handles,
         inside_handles_mode: inside_handles,
         latch_position: latch_position,
         bolt_position: bolt_position,
         door_state: door_state,
         timeout_minutes: timeout_minutes,
         timeout_seconds: timeout_seconds
       ]}
    else
      {:error, %DecodeError{} = decode_error} ->
        %DecodeError{decode_error | command: :door_lock_operation_report}
    end
  end

  # Version 4
  def decode_params(
        <<mode_byte, outside_handles_int::size(4), inside_handles_int::size(4),
          door_condition_byte, timeout_minutes, timeout_seconds, target_mode_byte, duration_byte>>
      ) do
    outside_handles = door_handles_modes_from_byte(outside_handles_int)
    inside_handles = door_handles_modes_from_byte(inside_handles_int)
    latch_position = latch_position_from_byte(door_condition_byte)
    bolt_position = bolt_position_from_byte(door_condition_byte)
    door_state = door_state_from_byte(door_condition_byte)

    with {:ok, mode} <- DoorLock.mode_from_byte(mode_byte),
         {:ok, timeout_minutes} <- timeout_minutes_from_byte(timeout_minutes),
         {:ok, timeout_seconds} <- timeout_seconds_from_byte(timeout_seconds),
         {:ok, target_mode} <- DoorLock.mode_from_byte(target_mode_byte),
         {:ok, duration} <- duration_from_byte(duration_byte) do
      {:ok,
       [
         mode: mode,
         outside_handles_mode: outside_handles,
         inside_handles_mode: inside_handles,
         latch_position: latch_position,
         bolt_position: bolt_position,
         door_state: door_state,
         timeout_minutes: timeout_minutes,
         timeout_seconds: timeout_seconds,
         target_mode: target_mode,
         duration: duration
       ]}
    end
  end

  def door_handles_modes_to_byte(handles_mode) do
    handle_1_bit = door_handle_value_to_bit(Map.get(handles_mode, 1, :disabled))
    handle_2_bit = door_handle_value_to_bit(Map.get(handles_mode, 2, :disabled))
    handle_3_bit = door_handle_value_to_bit(Map.get(handles_mode, 3, :disabled))
    handle_4_bit = door_handle_value_to_bit(Map.get(handles_mode, 4, :disabled))

    <<byte>> =
      <<0::size(4), handle_4_bit::size(1), handle_3_bit::size(1), handle_2_bit::size(1),
        handle_1_bit::size(1)>>

    byte
  end

  def door_condition_to_byte(latch_position, bolt_position, door_state) do
    latch_bit = latch_bit_from_position(latch_position)
    bolt_bit = bolt_bit_from_position(bolt_position)
    door_bit = door_bit_from_state(door_state)

    <<byte>> = <<0::size(5), latch_bit::size(1), bolt_bit::size(1), door_bit::size(1)>>

    byte
  end

  defp params_with_defaults(params) do
    handles_modes_default = %{1 => :disabled, 2 => :disabled, 3 => :disabled, 4 => :disabled}

    defaults = [
      inside_handles_mode: handles_modes_default,
      outside_handles_mode: handles_modes_default,
      latch_position: :open,
      bolt_position: :locked,
      door_state: :open,
      timeout_minutes: 0,
      timeout_seconds: 0
    ]

    Keyword.merge(defaults, params)
  end

  defp latch_bit_from_position(:open), do: 0
  defp latch_bit_from_position(:closed), do: 1

  defp bolt_bit_from_position(:locked), do: 0
  defp bolt_bit_from_position(:unlocked), do: 1

  defp door_bit_from_state(:open), do: 0
  defp door_bit_from_state(:closed), do: 1

  defp timeout_seconds_to_byte(s) when s >= 0 and s <= 0x3B, do: s
  defp timeout_seconds_to_byte(:undefined), do: 0xFE

  defp timeout_minutes_to_byte(m) when m >= 0 and m <= 0xFC, do: m
  defp timeout_minutes_to_byte(:undefined), do: 0xFE

  defp duration_to_byte(secs) when secs in 0..127, do: secs
  defp duration_to_byte(secs) when secs in 128..(126 * 60), do: round(secs / 60) + 0x7F
  defp duration_to_byte(:unknown), do: 0xFE

  defp door_handles_modes_from_byte(byte) do
    <<_::size(4), handle_4::size(1), handle_3::size(1), handle_2::size(1), handle_1::size(1)>> =
      <<byte>>

    %{
      1 => door_handle_enable_value_from_bit(handle_1),
      2 => door_handle_enable_value_from_bit(handle_2),
      3 => door_handle_enable_value_from_bit(handle_3),
      4 => door_handle_enable_value_from_bit(handle_4)
    }
  end

  defp door_handle_enable_value_from_bit(1), do: :enabled
  defp door_handle_enable_value_from_bit(0), do: :disabled

  defp door_handle_value_to_bit(:enabled), do: 1
  defp door_handle_value_to_bit(:disabled), do: 0

  defp latch_position_from_byte(byte) do
    <<_::size(5), latch_bit::size(1), _::size(2)>> = <<byte>>

    if latch_bit == 1 do
      :closed
    else
      :open
    end
  end

  defp bolt_position_from_byte(byte) do
    <<_::size(5), _::size(1), bolt_bit::size(1), _::size(1)>> = <<byte>>

    if bolt_bit == 1 do
      :unlocked
    else
      :locked
    end
  end

  defp door_state_from_byte(byte) do
    <<_::size(5), _::size(2), door_state_bit::size(1)>> = <<byte>>

    if door_state_bit == 1 do
      :closed
    else
      :open
    end
  end

  defp timeout_minutes_from_byte(m) when m >= 0 and m <= 0xFD, do: {:ok, m}
  defp timeout_minutes_from_byte(0xFE), do: {:ok, :undefined}

  defp timeout_minutes_from_byte(byte),
    do: {:error, %DecodeError{value: byte, param: :timeout_minute, command: :operation_report}}

  defp timeout_seconds_from_byte(s) when s >= 0 and s <= 0x3B, do: {:ok, s}
  defp timeout_seconds_from_byte(0xFE), do: {:ok, :undefined}

  defp timeout_seconds_from_byte(byte),
    do: {:error, %DecodeError{value: byte, param: :timeout_second, command: :operation_report}}

  defp duration_from_byte(byte) when byte in 0x00..0x7F, do: {:ok, byte}
  defp duration_from_byte(byte) when byte in 0x80..0xFD, do: {:ok, (byte - 0x7F) * 60}
  defp duration_from_byte(0xFE), do: :unknown

  defp duration_from_byte(byte),
    do: {:error, %DecodeError{value: byte, param: :duration, command: :supervision_report}}
end