Packages

BACstack is a low-level Elixir implementation for the ASHRAE standard 135, BACnet - Building Automation and Controller network. In its current published state, v0.0.x, it can (only) act as a BACnet client for other BACnet devices.

Current section

Files

Jump to
bacstack lib bacnet protocol host_n_port.ex
Raw

lib/bacnet/protocol/host_n_port.ex

defmodule BACnet.Protocol.HostNPort do
  @moduledoc """
  A BACnetHostNPort describes a host address together with a UDP (or equivalent)
  port number. It is used in several Network Port object properties, most
  notably:

  - `BACnet_IP_Global_Address` - the public-side address of a NAT-traversing port
  - `FD_BBMD_Address` - the BBMD with which a foreign device registers

  The host portion is a CHOICE that can be:

  - `:none` - no address is known / not configured (port should be 0)
  - an IP address (`:ip_address`, OCTET STRING, typically 4 or 16 octets)
  - a DNS host name (`:name`, CharacterString)

  ### ASN.1 (ASHRAE 135)

      BACnetHostAddress ::= CHOICE {
          none       [0] NULL,
          ip-address [1] OCTET STRING,
          name       [2] CharacterString
      }

      BACnetHostNPort ::= SEQUENCE {
          host [0] BACnetHostAddress,
          port [1] Unsigned16
      }
  """

  alias BACnet.Protocol.ApplicationTags

  @typedoc """
  The host part of a HostNPort.

  - `:none` - no address configured
  - `{:ip_address, :inet.ip_address()}` - raw IP address octets (IPv4 = 4 bytes, IPv6 = 16 bytes)
  - `{:name, String.t()}` - DNS host name (RFC 1123)
  """
  @type host :: :none | {:ip_address, :inet.ip_address()} | {:name, String.t()}

  @typedoc """
  A BACnetHostNPort value.
  """
  @type t :: %__MODULE__{
          host: host(),
          port: 0..65_535
        }

  @fields [:host, :port]
  @enforce_keys @fields
  defstruct @fields

  @doc """
  Encodes a BACnetHostNPort into application tags encoding (list of tags).
  """
  @spec encode(t(), Keyword.t()) ::
          {:ok, ApplicationTags.encoding_list()} | {:error, term()}
  def encode(%__MODULE__{} = hnp, opts \\ []) do
    with {:ok, host_tag} <- encode_host(hnp.host, opts),
         {:ok, port_tag} <- ApplicationTags.create_tag_encoding(1, :unsigned_integer, hnp.port) do
      {:ok, [{:constructed, {0, host_tag, 0}}, port_tag]}
    end
  end

  @doc """
  Parses a BACnetHostNPort from application tags encoding.

  Zero length hostnames (`{:name, _}`) will automatically be rewritten to `:none`.
  Some BACnet devices use invalid zero length hostnames instead of using `:none`,
  so we allow communication with those by changing them to `:none`.
  You can disable this behaviour by setting the
  application env `:bacstack, :hostnport_disable_host_zero_rewrite` to `true`
  """
  @spec parse(ApplicationTags.encoding_list()) ::
          {:ok, {t(), rest :: ApplicationTags.encoding_list()}} | {:error, term()}
  def parse(tags) when is_list(tags) do
    case tags do
      [
        {:constructed, {0, host_tags, _len1}},
        {:tagged, {1, port_bytes, _len2}}
        | rest
      ] ->
        with {:ok, host} <- parse_host(host_tags),
             {:ok, {:unsigned_integer, port}} <-
               ApplicationTags.unfold_to_type(:unsigned_integer, port_bytes) do
          if port >= 0 and port <= 65_535 do
            hnp = %__MODULE__{host: host, port: port}
            {:ok, {hnp, rest}}
          else
            {:error, :invalid_port}
          end
        else
          {:error, _err} = err -> err
        end

      _tags ->
        {:error, :invalid_tags}
    end
  end

  @doc """
  Validates whether the given HostNPort is well-formed.
  """
  @spec valid?(t()) :: boolean()
  def valid?(%__MODULE__{host: :none, port: port})
      when is_integer(port) and port >= 0 and port <= 65_535,
      do: true

  def valid?(%__MODULE__{host: {:ip_address, ip}, port: port})
      when is_tuple(ip) and tuple_size(ip) in [4, 8] and is_integer(port) and port >= 0 and
             port <= 65_535,
      do: true

  def valid?(%__MODULE__{host: {:name, name}, port: port})
      when is_binary(name) and byte_size(name) > 0 and byte_size(name) <= 255 and
             is_integer(port) and port >= 0 and port <= 65_535,
      do: true

  def valid?(%__MODULE__{}), do: false

  @spec encode_host(host(), Keyword.t()) :: {:ok, ApplicationTags.encoding()}
  defp encode_host(host, _opts)

  defp encode_host(:none, _opts) do
    # context tag 0, NULL (empty value)
    ApplicationTags.create_tag_encoding(0, :null, nil)
  end

  defp encode_host({:ip_address, ip}, _opts) when is_tuple(ip) and tuple_size(ip) == 4 do
    bytes =
      ip
      |> Tuple.to_list()
      |> :binary.list_to_bin()

    ApplicationTags.create_tag_encoding(1, :octet_string, bytes)
  end

  defp encode_host({:ip_address, ip}, _opts) when is_tuple(ip) and tuple_size(ip) == 8 do
    bytes =
      ip
      |> Tuple.to_list()
      |> Enum.map(fn int -> <<int::size(16)>> end)
      |> :binary.list_to_bin()

    ApplicationTags.create_tag_encoding(1, :octet_string, bytes)
  end

  defp encode_host({:name, name}, _opts) when is_binary(name) do
    ApplicationTags.create_tag_encoding(2, :character_string, name)
  end

  defp encode_host(_host, _opts), do: {:error, :invalid_host}

  @spec parse_host(ApplicationTags.encoding()) :: {:ok, host()} | {:error, term()}
  defp parse_host(tags)

  defp parse_host({:tagged, {0, _bytes, _len}}) do
    # NULL (context tag 0)
    {:ok, :none}
  end

  defp parse_host({:tagged, {1, bytes, len}}) when is_binary(bytes) and len == 4 do
    ip =
      bytes
      |> :binary.bin_to_list()
      |> List.to_tuple()

    {:ok, {:ip_address, ip}}
  end

  defp parse_host({:tagged, {1, bytes, len}}) when is_binary(bytes) and len == 16 do
    ip =
      bytes
      |> :binary.bin_to_list()
      |> Stream.chunk_every(2)
      |> Enum.map(fn [a, b] -> Bitwise.bsl(a, 8) + b end)
      |> List.to_tuple()

    {:ok, {:ip_address, ip}}
  end

  if not Application.compile_env(:bacstack, :hostnport_disable_host_zero_rewrite, false) do
    defp parse_host({:tagged, {2, <<_encoding>>, _len}}) do
      # Let's be conservative and re-write zero length hostname to NONE
      # Some devices incorrectly set {:name, ""} instead of :none,
      # so we re-write it to allow communication with those
      {:ok, :none}
    end
  end

  defp parse_host({:tagged, {2, bytes, _len}}) do
    case ApplicationTags.unfold_to_type(:character_string, bytes) do
      {:ok, {:character_string, name}} -> {:ok, {:name, name}}
      err -> err
    end
  end

  defp parse_host(_tags), do: {:error, :invalid_host}
end