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:///[/[/]] Where angle brackets indicate variable text and square brackets indicate optionality. The `` segment is the device instance number in decimal. A `` 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 `` identifier is in the form `,` where is either a decimal number or `t:BACnet.Protocol.Constants.object_type/0`, and is a decimal number. The `` 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 `` refers to the entire content of the file accessed with Stream Access. In that special case, `property` is set to `nil`. The `` 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