lib/grizzly/zwave/smart_start/meta_extension.ex

defmodule Grizzly.ZWave.SmartStart.MetaExtension do
  @moduledoc """
  Meta Extensions for SmartStart devices for QR codes and node provisioning
  list
  """

  alias Grizzly.ZWave
  alias Grizzly.ZWave.{DeviceClasses, IconType, Security}
  alias Grizzly.ZWave.SmartStart.MetaExtension.UUID16

  import Bitwise

  @advanced_joining 0x35
  @bootstrapping_mode 0x36
  @location_information 0x33
  @max_inclusion_request_interval 0x02
  @name_information 0x32
  @network_status 0x37
  @product_id 0x01
  @product_type 0x00
  @smart_start_inclusion_setting 0x34
  @uuid16 0x03

  @typedoc """
  Unsigned 16 bit integer
  """
  @type unit_16() :: char()

  @typedoc """
  The mode to use when including the node advertised in the provisioning list

  - `:security_2` - the node must be manually set to learn mode and follow the
    S2 bootstrapping instructions
  - `:smart_start` - the node will use S2 bootstrapping automatically using the
    SmartStart functionality
  - `:long_range` - included the device using the Z-Waver long range protocol.
    If no keys are granted in the `:advanced_joining` extension this inclusion
    will fail.
  """
  @type bootstrapping_mode() :: :security_2 | :smart_start | :long_range

  @typedoc """
  The different network statuses are:

  - `:not_in_network` - the node in the provisioning list is not included in
    the network
  - `:included` - the node in the provisioning list is included in the network
    and is functional
  - `:failing` - the node in the provisioning list is included in the network
    but is now marked as failing
  """
  @type network_status() :: :pending | :passive | :ignored

  @typedoc """
  Id of the manufacturer for the product id extension
  """
  @type manufacturer_id() :: unit_16()

  @typedoc """
  Id of the product produced by the manufacturer for the product id extension
  """
  @type product_id() :: unit_16()

  @typedoc """
  Type of product produced by the manufacturer for the product id extension
  """
  @type product_type() :: unit_16()

  @typedoc """
  Version of the application in a string format of "Major.Minor"
  """
  @type application_version() :: binary()

  @type product_id_values() ::
          {manufacturer_id(), product_id(), product_type(), application_version()}

  @typedoc """
  The interval (in seconds) must be in the range of 640..12672 inclusive, and
  has to be in steps of 128 seconds.

  So after 640 the next valid interval is `640 + 128` which is `768` seconds.

  See `SDS13944 Node Provisioning Information Type Registry.pdf` section
  `3.1.2.3` for more information.
  """
  @type inclusion_interval() :: 640..12672

  @typedoc """
  The location string cannot contain underscores and cannot end with a dash.

  The location string can contain a period (.) but a sublocation cannot end a
  dash. For example:

  ```
  123.123-.123
  ```

  The above location invalid. To make it valid remove the `-` before `.`.

  A node's location cannot be more than 62 bytes.
  """
  @type information_location() :: binary()

  @typedoc """
  The name string cannot contain underscores and cannot end with a dash.

  A node's name cannot be more than 62 bytes.
  """
  @type information_name() :: binary()

  @typedoc """
  Generic Device Class for the product type extension
  """
  @type generic_device_class() :: atom()

  @typedoc """
  Specific Device Class for the product type extension
  """
  @type specific_device_class() :: atom()

  @typedoc """
  Installer icon for the product type extension
  """
  @type installer_icon_type() :: IconType.name()

  @type product_type_values() ::
          {generic_device_class(), specific_device_class(), installer_icon_type()}

  @typedoc """
  Settings for the smart start inclusion setting exentsion

  * `:pending` - the node will be added to the network when it issues SmartStart
    inclusion requests.
  * `:passive` - this node is unlikely to issues a SmartStart inclusion request
    and SmartStart inclusion requests will be ignored from this node by the
    Z/IP Gateway. All nodes in the list with this setting must be updated to
    `:pending` when Provisioning List Iteration Get command is issued.
  * `:ignored` - All SmartStart inclusion request are ignored from this node
    until updated via Z/IP Client (Grizzly) or a controlling node.
  """
  @type inclusion_setting() :: :pending | :passive | :ignored

  @typedoc """
  Meta extension for SmartStart devices

  * `:advanced_joining` - used to specify which S2 security keys to grant
    during S2 inclusion
  * `:bootstrapping_mode` - used to specify the bootstrapping mode the including
    node must join with
  * `:location_information` - used to advertise the location assigned to the node
  * `:max_inclusion_request_interval` - used to advertise if a power constrained
    smart start node will issue an inclusion request at a higher interval than
    the default 512 seconds
  * `:name_information` - used to advertise the name of the node
  * `:network_status` - used to advertise if the node is in the network and its
    node id
  * `:product_id` - used to advertise product identifying data
  * `:product_type` - used to advertise the product type data
  * `:smart_start_inclusion_setting` - used to advertise the smart start
    inclusion setting
  * `:uuid16` - used to advertise the 16 byte manufacturer-defined information
    that is unique to the that device
  * `:unknown` - sometimes new extensions are released without first class
    support, so this extension is used for those extensions that still need to
    be supported in this library
  """
  @type extension() ::
          {:advanced_joining, [Security.key()]}
          | {:bootstrapping_mode, bootstrapping_mode()}
          | {:location_information, information_location()}
          | {:max_inclusion_request_interval, inclusion_interval()}
          | {:name_information, information_name()}
          | {:network_status, {ZWave.node_id(), atom()}}
          | {:product_id, product_id_values()}
          | {:product_type, product_type_values()}
          | {:smart_start_inclusion_setting, inclusion_setting()}
          | {:uuid16, UUID16.t()}
          | {:unknown, binary()}

  @doc """
  Encode an extension into a binary
  """
  @spec encode(extension()) :: binary()
  def encode(extension) do
    IO.iodata_to_binary(encode_extension(extension))
  end

  defp encode_extension({:advanced_joining, keys}) do
    keys_byte =
      Enum.reduce(keys, 0, fn
        :s2_unauthenticated, byte -> byte ||| 0x01
        :s2_authenticated, byte -> byte ||| 0x02
        :s2_access_control, byte -> byte ||| 0x04
        :s0, byte -> byte ||| 0x40
        _, byte -> byte
      end)

    [set_circuital_bit(@advanced_joining, 1), 0x01, keys_byte]
  end

  defp encode_extension({:bootstrapping_mode, mode}) do
    mode =
      case mode do
        :security_2 -> 0x00
        :smart_start -> 0x01
        :long_range -> 0x02
      end

    [set_circuital_bit(@bootstrapping_mode, 1), 0x01, mode]
  end

  defp encode_extension({:location_information, location}) do
    location =
      location
      |> String.codepoints()
      |> :erlang.list_to_binary()

    [set_circuital_bit(@location_information, 0), byte_size(location), location]
  end

  defp encode_extension({:max_inclusion_request_interval, interval}) do
    interval = Integer.floor_div(interval - 640, 128)

    [set_circuital_bit(@max_inclusion_request_interval, 0), 0x01, interval]
  end

  defp encode_extension({:name_information, name}) do
    name =
      name
      |> String.codepoints()
      |> Enum.reduce([], fn
        ".", nl ->
          nl ++ ["\\", "."]

        c, nl ->
          nl ++ [c]
      end)

    [set_circuital_bit(@name_information, 0), length(name), name]
  end

  defp encode_extension({:network_status, {node_id, status}}) do
    status =
      case status do
        :not_in_network -> 0x00
        :included -> 0x01
        :failing -> 0x02
      end

    [set_circuital_bit(@network_status, 0), 0x02, node_id, status]
  end

  defp encode_extension({:product_id, {manu_id, prod_id, prod_type, version}}) do
    {:ok, version} = Version.parse(version <> ".0")

    [
      set_circuital_bit(@product_id, 0),
      0x08,
      <<manu_id::size(16)>>,
      <<prod_id::size(16)>>,
      <<prod_type::size(16)>>,
      version.major,
      version.minor
    ]
  end

  defp encode_extension({:product_type, {gen_class, spec_class, icon_name}}) do
    gen_byte = DeviceClasses.generic_device_class_to_byte(gen_class)

    spec_byte =
      DeviceClasses.specific_device_class_to_byte(
        gen_class,
        spec_class
      )

    {:ok, icon_integer} = IconType.to_value(icon_name)

    [set_circuital_bit(@product_type, 0), 0x04, gen_byte, spec_byte, <<icon_integer::size(16)>>]
  end

  defp encode_extension({:smart_start_inclusion_setting, setting}) do
    setting =
      case setting do
        :pending -> 0x00
        :passive -> 0x02
        :ignored -> 0x03
      end

    [set_circuital_bit(@smart_start_inclusion_setting, 1), 0x01, setting]
  end

  defp encode_extension({:uuid16, uuid16}) do
    [UUID16.encode(uuid16)]
  end

  defp encode_extension({:unknown, binary}) do
    [binary]
  end

  @doc """
  Parse the binary into the list of extensions
  """
  @spec parse(binary()) :: [extension()]
  def parse(binary) do
    do_parse(binary, [])
  end

  defp do_parse(<<>>, extensions) do
    Enum.reverse(extensions)
  end

  defp do_parse(<<@advanced_joining::size(7), 1::size(1), 0x01, keys, rest::binary>>, extensions) do
    ext = {:advanced_joining, unmask_keys(keys)}

    do_parse(rest, [ext | extensions])
  end

  defp do_parse(
         <<@bootstrapping_mode::size(7), 1::size(1), 0x01, mode, rest::binary>>,
         extensions
       ) do
    mode =
      case mode do
        0x00 -> :security_2
        0x01 -> :smart_start
        0x02 -> :long_range
      end

    ext = {:bootstrapping_mode, mode}

    do_parse(rest, [ext | extensions])
  end

  defp do_parse(
         <<@location_information::size(7), 0::size(1), len, location::size(len)-unit(8)-binary,
           rest::binary>>,
         extensions
       ) do
    ext = {:location_information, to_string(location)}

    do_parse(rest, [ext | extensions])
  end

  defp do_parse(
         <<@max_inclusion_request_interval::size(7), 0::size(1), 0x01, interval, rest::binary>>,
         extensions
       ) do
    steps = interval - 5
    interval = 640 + steps * 128

    ext = {:max_inclusion_request_interval, interval}

    do_parse(rest, [ext | extensions])
  end

  defp do_parse(
         <<@name_information::size(7), 0::size(1), len, name::size(len)-unit(8)-binary,
           rest::binary>>,
         extensions
       ) do
    name =
      name
      |> to_string()
      |> String.replace("\\", "")

    ext = {:name_information, name}

    do_parse(rest, [ext | extensions])
  end

  defp do_parse(
         <<@network_status::size(7), 0::size(1), 0x02, node_id, status, rest::binary>>,
         extensions
       ) do
    status =
      case status do
        0x00 -> :not_in_network
        0x01 -> :included
        0x02 -> :failing
      end

    ext = {:network_status, {node_id, status}}

    do_parse(rest, [ext | extensions])
  end

  defp do_parse(
         <<@product_id::size(7), 0::size(1), 0x08, manu_id::size(16), prod_id::size(16),
           prod_type::size(16), version_major, version_minor, rest::binary>>,
         extensions
       ) do
    ext = {:product_id, {manu_id, prod_id, prod_type, "#{version_major}.#{version_minor}"}}

    do_parse(rest, [ext | extensions])
  end

  defp do_parse(
         <<@product_type::size(7), 0::size(1), 0x04, gen_class, spec_class, icon::size(16),
           rest::binary>>,
         extensions
       ) do
    {:ok, icon} = IconType.to_name(icon)
    {:ok, gen_class} = DeviceClasses.generic_device_class_from_byte(gen_class)

    {:ok, spec_class} = DeviceClasses.specific_device_class_from_byte(gen_class, spec_class)

    ext = {:product_type, {gen_class, spec_class, icon}}

    do_parse(rest, [ext | extensions])
  end

  defp do_parse(
         <<@smart_start_inclusion_setting::size(7), 1::size(1), 0x01, setting, rest::binary>>,
         extensions
       ) do
    setting =
      case setting do
        0x00 -> :pending
        0x02 -> :passive
        0x03 -> :ignored
      end

    ext = {:smart_start_inclusion_setting, setting}

    do_parse(rest, [ext | extensions])
  end

  defp do_parse(
         <<@uuid16::size(7), 0::size(1), len, values::size(len)-unit(8)-binary, rest::binary>>,
         extensions
       ) do
    {:ok, uuid} = UUID16.parse(<<@uuid16::size(7), 0::size(1), len, values::binary>>)

    ext = {:uuid16, uuid}

    do_parse(rest, [ext | extensions])
  end

  defp do_parse(<<type, len, values::size(len)-unit(8)-binary, rest::binary>>, extensions) do
    ext = {:unknown, <<type, len, values::binary>>}

    do_parse(rest, [ext | extensions])
  end

  defp unmask_keys(byte) do
    Enum.reduce(Security.keys(), [], fn key, keys ->
      if byte_has_key?(<<byte>>, key) do
        [key | keys]
      else
        keys
      end
    end)
  end

  defp byte_has_key?(<<_::size(7), 1::size(1)>>, :s2_unauthenticated), do: true
  defp byte_has_key?(<<_::size(6), 1::size(1), _::size(1)>>, :s2_authenticated), do: true
  defp byte_has_key?(<<_::size(5), 1::size(1), _::size(2)>>, :s2_access_control), do: true
  defp byte_has_key?(<<_::size(1), 1::size(1), _::size(6)>>, :s0), do: true
  defp byte_has_key?(_byte, _key), do: false

  defp set_circuital_bit(byte, cbit) do
    <<byte::size(7), cbit::size(1)>>
  end
end