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

lib/bacnet/protocol/access_specification.ex

defmodule BACnet.Protocol.AccessSpecification do
  @moduledoc """
  An Access Specification (also called Read Access Specification or Write Access
  Specification depending on context) describes a set of properties belonging to
  a single object that should be read or written together.

  It consists of an Object Identifier plus a list of Property references. Each
  property reference can name a specific property (with optional array index),
  or use one of the special values `:all`, `:required`, or `:optional`
  when reading. These special values instruct the server to return (or accept) all properties,
  only the required properties for that object type, or only the optional ones.

  Access Specifications are the fundamental building block of the Read Property
  Multiple and Write Property Multiple services. They allow a client to request
  or modify many properties across many objects in a single message while still
  grouping the properties logically by their owning object. This dramatically
  reduces the number of individual service requests needed for bulk
  configuration or polling.
  """

  # TODO: Throw argument error in encode if not valid

  alias BACnet.Protocol.AccessSpecification.Property
  alias BACnet.Protocol.ApplicationTags
  alias BACnet.Protocol.ObjectIdentifier

  import BACnet.Protocol.Utility, only: [pattern_extract_tags: 4]

  @typedoc """
  Specifies an object and a list of properties (or special keywords) for Read/Write Property Multiple.
  """
  @type t :: %__MODULE__{
          object_identifier: ObjectIdentifier.t(),
          properties: [Property.t() | :all | :required | :optional]
        }

  @fields [
    :object_identifier,
    :properties
  ]
  @enforce_keys @fields
  defstruct @fields

  @doc """
  Encodes a BACnet Read/Write Access Specification into BACnet application tags encoding.
  """
  @spec encode(t(), Keyword.t()) ::
          {:ok, ApplicationTags.encoding_list()} | {:error, term()}
  def encode(
        %__MODULE__{object_identifier: %ObjectIdentifier{}} = access_spec,
        _opts \\ []
      ) do
    with {:ok, object_identifier, _header} <-
           ApplicationTags.encode_value({:object_identifier, access_spec.object_identifier}),
         {:ok, properties} <- encode_access_spec_property_list(access_spec.properties) do
      params = [
        {:tagged, {0, object_identifier, byte_size(object_identifier)}},
        {:constructed, {1, properties, 0}}
      ]

      {:ok, params}
    end
  end

  @doc """
  Parses a BACnet Read/Write Access Specification from BACnet application tags encoding.
  """
  @spec parse(ApplicationTags.encoding_list()) ::
          {:ok, {t(), rest :: ApplicationTags.encoding_list()}}
          | {:error, term()}
  def parse(tags) when is_list(tags) do
    with {:ok, object, rest} <-
           pattern_extract_tags(
             tags,
             {:tagged, {0, _t, _l}},
             :object_identifier,
             false
           ),
         {:ok, {:constructed, {1, proplist, _len}}, rest} <-
           pattern_extract_tags(
             rest,
             {:constructed, {1, _t, _l}},
             nil,
             false
           ),
         {:ok, properties} <- parse_access_spec_property_list(proplist) do
      access_spec = %__MODULE__{
        object_identifier: object,
        properties: properties
      }

      {:ok, {access_spec, rest}}
    else
      {:error, :missing_pattern} -> {:error, :invalid_tags}
      {:error, _err} = err -> err
    end
  end

  @doc """
  Validates whether the given access specification is in form valid.

  It only validates the struct is valid as per type specification.

  Be aware, this function does not know whether it is a read or
  write access specification, thus it can't verify if the special
  property identifiers (atoms) are as per BACnet specification.
  Only read supports the special property identifiers.
  """
  @spec valid?(t()) :: boolean()
  def valid?(
        %__MODULE__{
          object_identifier: %ObjectIdentifier{} = obj,
          properties: [_props | _tl] = props
        } = _t
      ) do
    ObjectIdentifier.valid?(obj) and
      Enum.all?(props, fn
        :all -> true
        :required -> true
        :optional -> true
        %Property{} = prop -> Property.valid?(prop)
        _else -> false
      end)
  end

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

  defp parse_access_spec_property_list(tags) do
    result =
      Enum.reduce_while(1..8192//1, {tags, []}, fn
        _iter, {tags, acc} ->
          case Property.parse(tags) do
            {:ok, {item, []}} -> {:halt, {:ok, [item | acc]}}
            {:ok, {item, rest}} -> {:cont, {rest, [item | acc]}}
            {:error, _term} = term -> {:halt, term}
          end
      end)

    case result do
      {:ok, acc} -> {:ok, Enum.reverse(acc)}
      term -> term
    end
  end

  defp encode_access_spec_property_list(properties) do
    result =
      Enum.reduce_while(properties, {:ok, []}, fn
        property, {:ok, acc} ->
          case Property.encode(property) do
            {:ok, encoded} -> {:cont, {:ok, [encoded | acc]}}
            term -> {:halt, term}
          end
      end)

    case result do
      {:ok, list} ->
        new_list =
          list
          |> Enum.reverse()
          |> List.flatten()

        {:ok, new_list}

      term ->
        term
    end
  end
end