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

lib/bacnet/protocol/bacnet_uri.ex

defmodule BACnet.Protocol.BACnetURI do
  @moduledoc """
  ASHRAE 135 Annex Q.8 defines a BACnet URI scheme,
  in cases where a URI is needed to refer to data that is accessible using BACnet services.

  A BACnet URI is used to refer to BACnet objects,
  similar to `BACnet.Protocol.DeviceObjectPropertyRef`.

  The format looks like this:
  > bacnet://<device>/<object>[/<property>[/<index>]]

  Where angle brackets indicate variable text and square brackets indicate optionality.

  The `<device>` segment is the device instance number in decimal.
  A `<device>` identifier of `.this` means 'this device' so that it can be used in static files
  that do not need to be changed when the device identifier changes.

  The `<object>` identifier is in the form `<type>,<instance>` where <type> is either
  a decimal number or `t:BACnet.Protocol.Constants.object_type/0`,
  and <instance> is a decimal number.

  The `<property>` identifier is either a decimal number or
  `t:BACnet.Protocol.Constants.property_identifier/0`.
  If it is omitted, it defaults to `:present_value` except for BACnet File objects,
  where absence of `<property>` refers to the entire content of the file accessed with Stream Access.
  In that special case, `property` is set to `nil`.

  The `<index>` is the decimal number for the index of an array property.
  """

  alias BACnet.Protocol.Constants
  alias BACnet.Protocol.Constants.Object
  alias BACnet.Protocol.Constants.PropertyIdentifier
  alias BACnet.Protocol.ObjectIdentifier

  require Constants

  @typedoc """
  Available options for `encode/2`.

  The `always_decimal` option will force the function to
  always use decimal numbers in the URI output for better compatibility.
  Otherwise it will by default try to use the Clause 21 definition text,
  where possible.
  """
  @type encode_opt :: {:always_decimal, boolean()}

  @typedoc """
  Represents a BACnet URI.
  """
  @type t :: %__MODULE__{
          object_identifier: ObjectIdentifier.t(),
          property_identifier: Constants.property_identifier() | non_neg_integer() | nil,
          property_array_index: non_neg_integer() | nil,
          device_identifier: ObjectIdentifier.t() | nil
        }

  @fields [:device_identifier, :object_identifier, :property_identifier, :property_array_index]
  @enforce_keys @fields
  defstruct @fields

  @doc """
  Parses a BACnet URI string into a `BACnetURI` struct.

  Returns `{:ok, uri}` on success or `{:error, reason}` on failure.

  This function is more permissive than the specification,
  as in allows different casing and `_` for object types
  and property identifiers. Not only `analog-value` is allowed,
  but also `Analog_Value` or `analog_value`.

  ### Examples

  Local object:

      iex> BACnetURI.parse("bacnet://.this/1,5/85")
      {:ok, %BACnetURI{
        device_identifier: nil,
        object_identifier: %BACnet.Protocol.ObjectIdentifier{type: :analog_output, instance: 5},
        property_identifier: :present_value,
        property_array_index: nil
      }}

      iex> BACnetURI.parse("bacnet://.this/analog-output,5")
      {:ok, %BACnetURI{
        device_identifier: nil,
        object_identifier: %BACnet.Protocol.ObjectIdentifier{type: :analog_output, instance: 5},
        property_identifier: :present_value,
        property_array_index: nil
      }}

  Remote object with array index:

      iex> BACnetURI.parse("bacnet://114705/4,15555/priority-array/16")
      {:ok, %BACnetURI{
        device_identifier: %BACnet.Protocol.ObjectIdentifier{type: :device, instance: 114705},
        object_identifier: %BACnet.Protocol.ObjectIdentifier{type: :binary_output, instance: 15555},
        property_identifier: :priority_array,
        property_array_index: 16
      }}
  """
  @spec parse(String.t()) :: {:ok, t()} | {:error, term()}
  def parse(uri) when is_binary(uri) do
    with {:ok, %URI{scheme: "bacnet"} = url} <- URI.new(uri),
         {:ok, device} <- parse_device(url.host),
         {:ok, segments} <- parse_path(url.path),
         {:ok, {obj_str, prop_str, idx_str}} <- normalize_segments(segments),
         {:ok, object} <- parse_object(obj_str),
         {:ok, property} <- parse_property(prop_str, object),
         {:ok, index} <- parse_index(idx_str) do
      {:ok,
       %__MODULE__{
         device_identifier: device,
         object_identifier: object,
         property_identifier: property,
         property_array_index: index
       }}
    else
      {:error, _reason} = err -> err
      _other -> {:error, :invalid_bacnet_uri}
    end
  end

  @doc """
  Encodes a `BACnetURI` struct into a BACnet URI string.

  When the property is `nil` (for File objects), the property segment is omitted.
  Otherwise the property is always included.

  This function will use Clause 21 text for the URI encoding where possible and
  fallback to use the decimal number where necessary. It can be configured to always
  use the decimal number for better compability.
  If the object type or property identifier is a number, it will also be encoded as a number.
  It will not be converted to text.

  ### Examples

  Local object:

      iex> object = %BACnet.Protocol.ObjectIdentifier{type: :analog_output, instance: 5}
      iex> BACnetURI.encode(%BACnetURI{
      ...>   device_identifier: nil, object_identifier: object,
      ...>   property_identifier: :present_value, property_array_index: nil
      ...> })
      {:ok, "bacnet://.this/analog-output,5/present-value"}
      iex> BACnetURI.encode(%BACnetURI{
      ...>   device_identifier: nil, object_identifier: object,
      ...>   property_identifier: :present_value, property_array_index: nil
      ...> }, always_decimal: true)
      {:ok, "bacnet://.this/1,5/85"}

  Remote object with array index:

      iex> device = %BACnet.Protocol.ObjectIdentifier{type: :device, instance: 114705}
      iex> object = %BACnet.Protocol.ObjectIdentifier{type: :binary_output, instance: 15555}
      iex> BACnetURI.encode(%BACnetURI{
      ...>   device_identifier: device, object_identifier: object,
      ...>   property_identifier: :priority_array, property_array_index: 16
      ...> }, always_decimal: true)
      {:ok, "bacnet://114705/4,15555/87/16"}
  """
  @spec encode(t(), [encode_opt()]) :: {:ok, String.t()} | {:error, term()}
  def encode(uri, opts \\ [])

  def encode(
        %__MODULE__{
          device_identifier: device,
          object_identifier: %ObjectIdentifier{type: obj_type, instance: obj_inst} = object,
          property_identifier: prop,
          property_array_index: idx
        },
        opts
      )
      when (is_nil(device) or (is_struct(device, ObjectIdentifier) and device.type == :device)) and
             (is_nil(idx) or (is_integer(idx) and idx >= 0)) and is_list(opts) do
    always_decimal = !!opts[:always_decimal]

    device_str = if device, do: Integer.to_string(device.instance), else: ".this"

    obj_type_str = identifier_to_string(:object_type, obj_type, always_decimal)
    obj_str = "#{obj_type_str},#{obj_inst}"

    path =
      if omit_property?(prop, object) do
        obj_str
      else
        prop_str = identifier_to_string(:property_identifier, prop, always_decimal)
        pstr = obj_str <> prop_str

        if idx do
          pstr <> "/" <> Integer.to_string(idx)
        else
          pstr
        end
      end

    uri = URI.to_string(%URI{scheme: "bacnet", host: device_str, path: path})
    {:ok, uri}
  end

  def encode(%__MODULE__{} = _uri, _opts), do: {:error, :invalid_data}

  @doc """
  Returns `true` if the `BACnetURI` struct contains valid data according to
  the BACnet URI scheme rules (Annex Q.8).
  """
  @spec valid?(t()) :: boolean()
  def valid?(%__MODULE__{
        device_identifier: device,
        object_identifier: object,
        property_identifier: property,
        property_array_index: index
      }) do
    (is_nil(device) or
       (is_struct(device, ObjectIdentifier) and ObjectIdentifier.valid?(device) and
          device.type == :device)) and
      (is_struct(object, ObjectIdentifier) and ObjectIdentifier.valid?(object)) and
      ((is_nil(property) and object.type == :file) or
         (Constants.has_by_name(:property_identifier, property) or
            (is_integer(property) and property >= 0 and
               property <= Constants.macro_by_name(:asn1, :max_instance_and_property_id)))) and
      (is_nil(index) or (is_integer(index) and index >= 0))
  end

  @doc """
  Returns `true` if the given BACnet URI string is valid.

  This function will attempt to parse the string and then validate it.
  """
  @spec valid_str?(binary()) :: boolean()
  def valid_str?(uri) when is_binary(uri) do
    case parse(uri) do
      {:ok, %__MODULE__{} = url} -> valid?(url)
      {:error, _err} -> false
    end
  end

  @spec parse_device(binary() | nil) :: {:ok, ObjectIdentifier.t() | nil} | {:error, term()}
  defp parse_device(device)

  defp parse_device(nil), do: {:error, :invalid_bacnet_uri}
  defp parse_device(".this"), do: {:ok, nil}

  defp parse_device(host) when is_binary(host) do
    case Integer.parse(host) do
      {num, ""}
      when num >= 0 and num <= Constants.macro_by_name(:asn1, :max_instance_and_property_id) ->
        {:ok, %ObjectIdentifier{type: :device, instance: num}}

      _other ->
        {:error, :invalid_device}
    end
  end

  @spec parse_path(binary() | nil) :: {:ok, list()} | {:error, term()}
  defp parse_path(path)

  defp parse_path(nil), do: {:ok, []}

  defp parse_path("/" <> rest) when is_binary(rest) do
    segments =
      case rest do
        "" -> []
        _other -> String.split(rest, "/")
      end

    {:ok, segments}
  end

  defp parse_path(_path), do: {:error, :invalid_path}

  @spec normalize_segments([binary()]) ::
          {:ok, {binary(), binary() | nil, binary() | nil}} | {:error, term()}
  defp normalize_segments(segments)

  defp normalize_segments([]), do: {:error, :missing_object}
  defp normalize_segments([obj]), do: {:ok, {obj, nil, nil}}
  defp normalize_segments([obj, prop]), do: {:ok, {obj, prop, nil}}
  defp normalize_segments([obj, prop, idx]), do: {:ok, {obj, prop, idx}}
  defp normalize_segments(_segments), do: {:error, :invalid_path_segments}

  @spec parse_object(binary()) :: {:ok, ObjectIdentifier.t()} | {:error, term()}
  defp parse_object(str)

  defp parse_object(obj_str) when is_binary(obj_str) do
    case String.split(obj_str, ",", parts: 2) do
      [type_str, inst_str] ->
        with {:ok, type} <- parse_object_type(type_str),
             {:ok, instance} <- parse_instance(inst_str) do
          {:ok, %ObjectIdentifier{type: type, instance: instance}}
        end

      _other ->
        {:error, :invalid_object}
    end
  end

  defp parse_object_type(type_str) when is_binary(type_str) do
    # We are more permissive than the BACnet specification
    # "exactly equal to the Clause 21 identifier text of BACnetObjectType"
    # -> analog-value (valid), Analog_Value (invalid), analog_value (invalid)
    type = parse_object_type1(type_str) || parse_object_type2(type_str)

    try do
      atom =
        if is_atom(type) do
          type
        else
          String.to_existing_atom(type)
        end

      Constants.assert_name!(:object_type, atom)
      {:ok, atom}
    rescue
      _other ->
        case Integer.parse(type_str) do
          {num, ""} when num >= 0 and num <= Constants.macro_by_name(:asn1, :max_object_type) ->
            {:ok, Constants.by_value(:object_type, num, num)}

          _other ->
            {:error, :invalid_object_type}
        end
    end
  end

  @spec parse_object_type1(String.t()) :: atom() | nil
  defp parse_object_type1(type_str) do
    atom = String.to_existing_atom(type_str)
    Constants.assert_name!(:object_type, atom)

    atom
  rescue
    _exc ->
      translation = Object.clause21_translation()

      Enum.find_value(translation, fn
        {key, ^type_str} -> key
        _other -> nil
      end)
  end

  @spec parse_object_type2(String.t()) :: String.t() | nil
  defp parse_object_type2(type_str) do
    type_str
    |> String.replace("-", "_")
    |> String.downcase()
  end

  @spec parse_instance(binary()) :: {:ok, non_neg_integer()} | {:error, term()}
  defp parse_instance(str)

  defp parse_instance(str) when is_binary(str) do
    case Integer.parse(str) do
      {num, ""}
      when num >= 0 and num <= Constants.macro_by_name(:asn1, :max_instance_and_property_id) ->
        {:ok, num}

      _other ->
        {:error, :invalid_instance}
    end
  end

  @spec parse_property(binary() | nil, ObjectIdentifier.t()) ::
          {:ok, Constants.property_identifier() | non_neg_integer() | nil} | {:error, term()}
  defp parse_property(str, object)

  defp parse_property(nil, %ObjectIdentifier{type: :file}), do: {:ok, nil}
  defp parse_property(nil, _object), do: {:ok, :present_value}

  defp parse_property(prop_str, _object) when is_binary(prop_str) do
    prop = parse_property1(prop_str) || parse_property2(prop_str)

    try do
      atom =
        if is_atom(prop) do
          prop
        else
          String.to_existing_atom(prop)
        end

      Constants.assert_name!(:property_identifier, atom)
      {:ok, atom}
    rescue
      _other ->
        case Integer.parse(prop_str) do
          {num, ""} -> {:ok, Constants.by_value(:property_identifier, num, num)}
          _other -> {:error, :invalid_property}
        end
    end
  end

  @spec parse_property1(String.t()) :: atom() | nil
  defp parse_property1(prop_str) do
    atom = String.to_existing_atom(prop_str)
    Constants.assert_name!(:property_identifier, atom)

    atom
  rescue
    _exc ->
      translation = PropertyIdentifier.clause21_translation()

      Enum.find_value(translation, fn
        {key, ^prop_str} -> key
        _other -> nil
      end)
  end

  @spec parse_property2(String.t()) :: String.t() | nil
  defp parse_property2(prop_str) do
    prop_str
    |> String.replace("-", "_")
    |> String.downcase()
  end

  @spec parse_index(binary() | nil) :: {:ok, non_neg_integer() | nil} | {:error, term()}
  defp parse_index(str)

  defp parse_index(nil), do: {:ok, nil}

  defp parse_index(str) when is_binary(str) do
    case Integer.parse(str) do
      {num, ""} when num >= 0 -> {:ok, num}
      _other -> {:error, :invalid_index}
    end
  end

  @spec omit_property?(atom() | non_neg_integer() | nil, ObjectIdentifier.t()) :: boolean()
  defp omit_property?(property, object)

  defp omit_property?(nil, %ObjectIdentifier{type: :file}), do: true
  defp omit_property?(_prop, _object), do: false

  @spec identifier_to_string(atom(), atom() | non_neg_integer() | nil, boolean()) :: binary()
  defp identifier_to_string(category, value, always_decimal)

  defp identifier_to_string(_category, nil, _always_decimal), do: ""

  defp identifier_to_string(:object_type, val, false) when is_atom(val) do
    id =
      Object.translate_to_clause21(val) ||
        Integer.to_string(Constants.by_name!(:object_type, val))

    "/" <> id
  end

  defp identifier_to_string(:property_identifier, val, false) when is_atom(val) do
    id =
      PropertyIdentifier.translate_to_clause21(val) ||
        Integer.to_string(Constants.by_name!(:property_identifier, val))

    "/" <> id
  end

  defp identifier_to_string(category, val, _always_decimal) when is_atom(val) do
    "/" <> Integer.to_string(Constants.by_name!(category, val))
  end

  defp identifier_to_string(_category, val, _always_decimal) when is_integer(val) do
    "/" <> Integer.to_string(val)
  end

  defp identifier_to_string(_category, _val, _always_decimal), do: ""
end