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 name_value.ex
Raw

lib/bacnet/protocol/name_value.ex

defmodule BACnet.Protocol.NameValue do
  @moduledoc """
  BACnetNameValue is a structure used primarily for the `Tags` property
  (of type BACnetARRAY of BACnetNameValue) that exists on nearly every
  standardized BACnet object type (ASHRAE 135-2016 Clause 12.x and Annex Y).

  Each entry associates a name (CharacterString) with an optional value.
  When the value is absent the tag is considered a "semantic tag".
  To allow tag names to be concatenated,
  tag names shall not contain semicolon characters (ASHRAE 135-2016 Y.1.4).

  Simple tag names, e.g., `exhaust`, are reserved for definition by ASHRAE.
  Tag names defined by organizations other than ASHRAE shall use a prefix to ensure uniqueness.
  This prefix shall be either:
  1) A reversed registered DNS name, followed by a period character.  e.g., `com.example.`, or
  2) A BACnet vendor identifier in decimal, followed by a dash character. e.g., `555-`

  The value, when present, is limited to any primitive BACnet datatype.

  ### ASN.1 (ASHRAE 135-2016)

      BACnetNameValue ::= SEQUENCE {
        name  [0] CharacterString,
        value ABSTRACT-SYNTAX.&Type OPTIONAL
          -- value is limited to primitive datatypes
      }
  """

  alias BACnet.Protocol.ApplicationTags
  alias BACnet.Protocol.ApplicationTags.Encoding
  alias BACnet.Protocol.Utility

  import Utility, only: [pattern_extract_tags: 4]

  @typedoc """
  A single BACnetNameValue entry.

  Only primitive `Encoding` is allowed as value,
  otherwise the value is absent (`nil`).
  """
  @type t :: %__MODULE__{
          name: String.t(),
          value: Encoding.t() | nil
        }

  @fields [:name, :value]
  @enforce_keys @fields
  defstruct @fields

  @doc """
  Parses a BACnetNameValue from application tags encoding (context tagged elements).
  """
  @spec parse(ApplicationTags.encoding_list()) ::
          {:ok, {t(), rest :: ApplicationTags.encoding_list()}} | {:error, term()}
  def parse(tags) when is_list(tags) do
    with {:ok, name, rest} <-
           pattern_extract_tags(tags, {:tagged, {0, _val, _len}}, :character_string, false),
         {:ok, {value, rest}} <-
           (case rest do
              [value | rest] ->
                # Some BACnet devices (i.e. Delta Controls) put the value into encoding tag 1,
                # despite the BACnet spec being clear that there's no tag,
                # even since the introduction in 135-2016 - so we'll be permissive
                value0 =
                  case value do
                    {:constructed, {1, tag, 0}} -> tag
                    _other -> value
                  end

                case Encoding.create(value0) do
                  {:ok, %Encoding{encoding: :primitive} = enc} -> {:ok, {enc, rest}}
                  {:ok, _other} -> {:error, :invalid_value}
                  other -> other
                end

              _other ->
                {:ok, {nil, rest}}
            end) do
      nv = %__MODULE__{name: name, value: value}
      {:ok, {nv, rest}}
    else
      {:error, :missing_pattern} -> {:error, :invalid_tags}
      {:error, _err} = err -> err
    end
  end

  @doc """
  Encodes a BACnetNameValue into application tags encoding.
  """
  @spec encode(t(), Keyword.t()) ::
          {:ok, ApplicationTags.encoding_list()} | {:error, term()}
  def encode(%__MODULE__{name: name, value: value}, _opts \\ []) do
    with true <- is_binary(name) and byte_size(name) > 0,
         {:ok, name_tag} <- ApplicationTags.create_tag_encoding(0, :character_string, name) do
      case value do
        nil ->
          {:ok, [name_tag]}

        %Encoding{encoding: :primitive} ->
          with {:ok, value_tag} <- Encoding.to_encoding(value) do
            {:ok, [name_tag, value_tag]}
          end

        _other ->
          {:error, :invalid_value}
      end
    else
      false -> {:error, :invalid_name}
      {:error, _err} = err -> err
    end
  end

  @doc """
  Returns true if the NameValue struct contains a valid name and (optionally) a
  well-formed value of a supported type.
  """
  @spec valid?(t()) :: boolean()
  def valid?(%__MODULE__{name: name, value: value} = _t)
      when is_binary(name) and byte_size(name) > 0 do
    # ASHRAE Y.1.4 specifies:
    # "To allow tag names to be concatenated, tag names shall not contain semicolon characters."
    not String.contains?(name, ";") and
      case value do
        nil -> true
        %Encoding{encoding: :primitive} -> true
        _other -> false
      end
  end

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