defmodule BACnet.Protocol.ObjectsUtility do @moduledoc """ This module offers utility functions that work on all object types. This is mostly complementary to the object module itself and offers some additional generic functions for working with objects. Some functions will call `Code.ensure_loaded/1` on object modules to ensure the module is loaded and available - however only if `Mix.env/0` does not return `:prod`. If this library is a dependency in a project, Mix always compiles dependencies in `:prod`. To override this behaviour, see `mix help deps`. """ alias __MODULE__.Internal.ReadPropertyAckTransformOptions, as: RPATransformOptions alias BACnet.Internal alias BACnet.Protocol.ApplicationTags.Encoding alias BACnet.Protocol.BACnetArray alias BACnet.Protocol.Constants alias BACnet.Protocol.ObjectIdentifier alias BACnet.Protocol.ObjectTypes alias BACnet.Protocol.PriorityArray alias BACnet.Protocol.ReadAccessResult alias BACnet.Protocol.Services.Ack.ReadPropertyAck alias BACnet.Protocol.Services.Ack.ReadPropertyMultipleAck require Constants @object_type_mappings_key {__MODULE__, :object_type_mappings} @types [ ObjectTypes.Accumulator, ObjectTypes.AnalogInput, ObjectTypes.AnalogOutput, ObjectTypes.AnalogValue, ObjectTypes.Averaging, ObjectTypes.BinaryInput, ObjectTypes.BinaryOutput, ObjectTypes.BinaryValue, ObjectTypes.BitstringValue, ObjectTypes.Calendar, ObjectTypes.CharacterStringValue, ObjectTypes.Command, ObjectTypes.DatePatternValue, ObjectTypes.DateTimePatternValue, ObjectTypes.DateTimeValue, ObjectTypes.DateValue, ObjectTypes.Device, ObjectTypes.EventEnrollment, ObjectTypes.EventLog, ObjectTypes.File, ObjectTypes.Group, ObjectTypes.IntegerValue, ObjectTypes.LargeAnalogValue, ObjectTypes.Loop, ObjectTypes.MultistateInput, ObjectTypes.MultistateOutput, ObjectTypes.MultistateValue, ObjectTypes.NotificationClass, ObjectTypes.OctetStringValue, ObjectTypes.PositiveIntegerValue, ObjectTypes.Program, ObjectTypes.PulseConverter, ObjectTypes.Schedule, ObjectTypes.StructuredView, ObjectTypes.TimePatternValue, ObjectTypes.TimeValue, ObjectTypes.TrendLog, ObjectTypes.TrendLogMultiple ] types_spec = @types |> Enum.map(fn type -> quote do unquote(type).t() end end) |> Enum.sort(:desc) |> Enum.reduce(fn field, acc -> {:|, [], [field, acc]} end) defmacrop get_fa_str() do {fun, arity} = __CALLER__.function str = "#{fun}/#{arity}" quote do unquote(str) end end @typedoc """ BACnet object types that this module works with. """ @type bacnet_object :: unquote(types_spec) @typedoc """ Valid options for `cast_property_to_value/4`. """ @type cast_property_to_value_option :: {:allow_partial, boolean()} @typedoc """ Valid options for `cast_properties_to_object/3`. `allow_unknown_properties` allows `:no_unpack` as synonym for `true`. """ @type cast_properties_to_object_option :: {:allow_unknown_properties, boolean() | :no_unpack} | {:ignore_unknown_properties, boolean()} | {:remote_device_id, non_neg_integer()} | {:revision, Constants.protocol_revision()} | {:object_opts, Keyword.t()} @typedoc """ Valid options for `cast_read_properties_ack/3`. """ @type cast_read_properties_ack_option :: {:allow_unknown_properties, boolean() | :no_unpack} | {:ignore_array_indexes, boolean()} | {:ignore_invalid_properties, boolean()} | {:ignore_object_identifier_mismatch, boolean()} | {:ignore_unknown_properties, boolean()} @typedoc """ Valid options for `cast_value_to_property/4`. """ @type cast_value_to_property_option :: {:allow_nil, boolean()} | {:allow_partial, boolean()} @doc """ Checks whether the given struct is a supported BACnet object (see `t:bacnet_object/0`). Note: This guard is not widely used by this module itself, but may be useful for others. """ defguard is_object(object) when is_struct(object) and :erlang.map_get(:__struct__, object) in @types @doc """ Checks whether the given BACnet object has Intrinsic Reporting enabled. This is the same functionality as `intrinsic_reporting?/1`, but as a guard. """ defguard is_object_intrinsic(object) when is_map(object) and :erlang.map_get(:intrinsic_reporting, :erlang.map_get(:_metadata, object)) == true @doc """ Checks whether the given BACnet object is a local object (identified through metadata). A local object is a BACnet object that resides in the local BACnet device (this bacstack). """ defguard is_object_local(object) when is_map(object) and :erlang.map_get(:remote_object, :erlang.map_get(:_metadata, object)) == nil @doc """ Checks whether the given BACnet object is a remote object (identified through metadata). A remote object is a BACnet object that resides in a remote BACnet device - as such some operations don't work (such as adding optional properties). """ defguard is_object_remote(object) when is_map(object) and is_integer(:erlang.map_get(:remote_object, :erlang.map_get(:_metadata, object))) and :erlang.map_get(:remote_object, :erlang.map_get(:_metadata, object)) != nil # defmacrop is_env(env, do: code), do: if(Mix.env() == env, do: code) defmacrop unless_env(env, do: code), do: unless(Mix.env() == env, do: code) @doc """ Get the object type to module mappings. This mapping is used for object properties casting (such as `cast_property_to_value/4`). This mapping is stored in `:persistent_term` and is automatically populated on first use with all object types from the `bacstack` application. """ @spec get_object_type_mappings() :: %{optional(Constants.object_type()) => module()} def get_object_type_mappings() do case :persistent_term.get(@object_type_mappings_key, nil) do # If the key doesn't exist yet, populate it from our application nil -> case :application.get_key(:bacstack, :modules) do {:ok, modules} -> mods = modules |> Enum.filter(&String.contains?(Atom.to_string(&1), ".ObjectTypes.")) |> Enum.flat_map(fn module -> case module.__info__(:attributes)[:bacnet_object] do [type] -> [{type, module}] _else -> [] end end) |> Enum.into(%{}) :persistent_term.put(@object_type_mappings_key, mods) mods _err -> raise "Unable to determine BACnet object types from application bacstack" end map -> map end end @doc """ Put an object type to module relationship into the mappings. See also `get_object_type_mappings/0` for more information. """ @spec put_object_type_mapping(Constants.object_type(), module()) :: :ok def put_object_type_mapping(object_type, module) when is_atom(object_type) and is_atom(module) do get_object_type_mappings() |> Map.put(object_type, module) |> then(&:persistent_term.put(@object_type_mappings_key, &1)) end @doc """ Put many object type to module relationships into the mappings at once. See also `get_object_type_mappings/0` for more information. """ @spec put_many_object_type_mapping([{Constants.object_type(), module()}]) :: :ok def put_many_object_type_mapping(mappings) when is_list(mappings) do unless Enum.all?(mappings, fn {type, mod} -> is_atom(type) and is_atom(mod) _else -> false end) do raise ArgumentError, "Mappings is not a list of tuple with both elements being an atom, " <> "got: " <> inspect(mappings) end get_object_type_mappings() |> then(&Enum.reduce(mappings, &1, fn {key, val}, acc -> Map.put(acc, key, val) end)) |> then(&:persistent_term.put(@object_type_mappings_key, &1)) end @doc """ Delete an object type to module relationship from the mappings. See also `get_object_type_mappings/0` for more information. """ @spec delete_object_type_mapping(Constants.object_type()) :: :ok def delete_object_type_mapping(object_type) when is_atom(object_type) do get_object_type_mappings() |> Map.delete(object_type) |> then(&:persistent_term.put(@object_type_mappings_key, &1)) end @doc """ Get the object identifier for the BACnet object. The `bacnet_object` contract is enforced. """ @spec get_object_identifier(bacnet_object()) :: ObjectIdentifier.t() def get_object_identifier(%_any{object_instance: instance} = object) when is_object(object) do %ObjectIdentifier{ type: get_object_type(object), instance: instance } end @doc """ Get the BACnet object type. The `bacnet_object` contract is enforced. """ @spec get_object_type(bacnet_object()) :: Constants.object_type() def get_object_type(%mod{} = _object) do type = mod.__info__(:attributes)[:bacnet_object] unless is_list(type) and length(type) == 1 do raise ArgumentError, "Invalid BACnet object type module, missing or invalid persistent attribute :bacnet_object" end hd(type) end @doc """ Get the list of properties the object has. """ @spec get_properties(bacnet_object()) :: [Constants.property_identifier()] def get_properties(%{_metadata: %{properties_list: list}} = _object) do list end @doc """ Checks if the given object has the given property. > #### Implementation Detail {: .info} > This function is O(n), as it traverses the properties list. > This actually represents what on the BACnet side can be seen, as only properties in the > properties list can be used (observable). """ @spec has_property?(bacnet_object(), Constants.property_identifier()) :: boolean() def has_property?(%_any{_metadata: %{properties_list: list}} = _object, property) when is_atom(property) do property in list end @doc """ Checks whether the given object has a priority array. This function does not verify if the property is in the properties list of the object. """ @spec has_priority_array?(bacnet_object()) :: boolean() def has_priority_array?(%_any{priority_array: %PriorityArray{} = _array} = _object), do: true def has_priority_array?(%_any{} = _object), do: false @doc """ Checks if the given object has Intrinsic Reporting enabled. """ @spec intrinsic_reporting?(bacnet_object()) :: boolean() def intrinsic_reporting?(%_any{_metadata: %{intrinsic_reporting: intrin}} = _object) do intrin end @doc """ Checks if the given property is writable. This implementation checks for arbitary properties, if the property exists and is not annotated as readonly. For commandable objects and the present value, it checks if the object is out of service. For the event algorithm inhibit property, it checks if ref is absent or uninitialized and event detection is enabled. Object-specific behaviour are not checked and should instead be directly checked through the object module. """ @spec property_writable?(bacnet_object(), Constants.property_identifier()) :: boolean() def property_writable?(%bac{} = object, property) when is_atom(property) do if has_property?(object, property) do case property do :event_algorithm_inhibit -> case object do %{event_algorithm_inhibit_ref: ref, event_detection_enable: true} when not is_nil(ref) -> ref.object_identifier.instance == Constants.macro_by_name(:asn1, :max_instance_and_property_id) %{event_algorithm_inhibit_ref: nil, event_detection_enable: true} -> true _term -> false end :present_value -> case object do %{priority_array: pa} when not is_nil(pa) -> !!Map.get(object, :out_of_service) _term -> true end _term -> if function_exported?(bac, :get_readonly_properties, 0) do not Enum.member?(bac.get_readonly_properties(), property) else true end end else false end end @doc """ Adds an optional property to an object (see `t:bacnet_object/0`). Please note that properties of services can **not** be dynamically added and instead the object must be newly created. This function calls to the object module directly, as such it enforces the `t:bacnet_object/0` contract. """ @spec add_property(bacnet_object(), Constants.property_identifier(), term()) :: {:ok, bacnet_object()} | {:error, term()} def add_property(%bac{} = object, property, value) when is_object(object) and is_atom(property) do bac.add_property(object, property, value) end @doc """ Get the property of an object (see `t:bacnet_object/0`). This function calls to the object module directly, as such it enforces the `t:bacnet_object/0` contract. """ @spec get_property(bacnet_object(), Constants.property_identifier()) :: {:ok, term()} | {:error, term()} def get_property(%bac{} = object, property) when is_object(object) and is_atom(property) do bac.get_property(object, property) end @doc """ Removes an optional property from an object (see `t:bacnet_object/0`). This function is idempotent. Please note that properties of services can **not** be dynamically removed and instead the object must be newly created. Required properties can not be removed. This function calls to the object module directly, as such it enforces the `t:bacnet_object/0` contract. """ @spec remove_property(bacnet_object(), Constants.property_identifier()) :: {:ok, bacnet_object()} | {:error, term()} def remove_property(%bac{} = object, property) when is_object(object) and is_atom(property) do bac.remove_property(object, property) end @doc """ Updates a property of an object (see `t:bacnet_object/0`). This function calls to the object module directly, as such it enforces the `t:bacnet_object/0` contract. """ @spec update_property(bacnet_object(), Constants.property_identifier(), term()) :: {:ok, bacnet_object()} | {:error, term()} def update_property(%bac{} = object, property, value) when is_object(object) and is_atom(property) do bac.update_property(object, property, value) end @doc """ Get the active priority value from the priority array, or nil. """ @spec get_priority_value(bacnet_object()) :: {priority :: 1..16, value :: term()} | nil def get_priority_value(%{priority_array: %PriorityArray{} = prio_array} = _object) do PriorityArray.get_value(prio_array) end def get_priority_value(%{} = _object) do nil end @doc """ Sets the given priority in the priority array of an object (see `t:bacnet_object/0`). This function also updates the present value. This function calls to the object module directly, as such it enforces the `t:bacnet_object/0` contract, and additionally only objects with a priority array can be used. """ @spec set_priority(bacnet_object(), 1..16, term()) :: {:ok, bacnet_object()} | {:error, term()} def set_priority(%{priority_array: nil} = _object, _priority, _value) do {:error, {:unknown_property, :priority_array}} end def set_priority(%bac{priority_array: %PriorityArray{} = _pa} = object, priority, value) when is_object(object) do bac.set_priority(object, priority, value) end @doc """ Get the remote device ID for remote objects. For remote objects with no remote device ID attached, this function will return `:error`. For local objects, this function will return `nil`. """ @spec get_remote_device_id(bacnet_object()) :: {:ok, non_neg_integer()} | :error | nil def get_remote_device_id(object) def get_remote_device_id(%{_metadata: %{remote_object: true}}) do :error end def get_remote_device_id(%{_metadata: %{remote_object: rem}}) when is_integer(rem) do {:ok, rem} end def get_remote_device_id(%{} = _object) do nil end @doc """ Truncates each property, which is a float, to the given precision (float rounding). When giving an integer as precision, this function will behave just like `Float.round/2`. When giving a float as precision, this function will determine the precision (i.e. 1 for `0.1`, 2 for `0.01`, 0 for any value >= `1.0` or `0.0`). The float value itself is not relevant, only how many decimal points there are. Depending on the selected mode, the float will be rounded or the value is truncated. """ @spec truncate_float_properties( bacnet_object(), float() | non_neg_integer(), :round | :truncate ) :: bacnet_object() def truncate_float_properties(object, precision, mode \\ :round) def truncate_float_properties(%_bac{} = object, precision, mode) when is_integer(precision) and precision >= 0 and mode in [:round, :truncate] do truncate_floats_object(object, precision, mode) end def truncate_float_properties(%_bac{} = object, -0.0, mode) when mode in [:round, :truncate] do truncate_floats_object(object, 0, mode) end def truncate_float_properties(%_bac{} = object, +0.0, mode) when mode in [:round, :truncate] do truncate_floats_object(object, 0, mode) end def truncate_float_properties(%_bac{} = object, precision, mode) when is_float(precision) and precision >= 1.0 and mode in [:round, :truncate] do truncate_floats_object(object, 0, mode) end def truncate_float_properties(%_bac{} = object, precision, mode) when is_float(precision) and precision < 1.0 and mode in [:round, :truncate] do resolution = precision |> Float.to_string() |> String.split(".") |> tl() |> hd() |> byte_size() |> min(15) truncate_floats_object(object, resolution, mode) end defp truncate_floats_object(%name{} = object, precision, mode) do object |> Map.from_struct() |> Enum.reduce(object, fn {key, %PriorityArray{} = pa}, acc -> Map.put(acc, key, truncate_floats_object(pa, precision, mode)) {key, val}, acc when is_float(val) -> Map.put(acc, key, truncate_floats(val, precision, mode)) _pair, acc -> acc end) |> then(&struct(name, &1)) end defp truncate_floats(float, precision, :round), do: Float.round(float, precision) defp truncate_floats(float, precision, :truncate) do pow = Integer.pow(10, precision) trunc(float * pow) / pow end @doc """ Casts a property from application tag `Encoding` to a more sane data type. To cast the property to the proper data type, the correct object module needs to be known, which contains that property. The object module will tell us which data type should it be. As such, an object identifier is required. Note: In prod environment, required modules are not explicitely loaded. The following options are available: - `allow_partial: boolean()` - Optional. Allows partial values of array or list properties (a single value). """ @spec cast_property_to_value( ObjectIdentifier.t(), Constants.property_identifier(), Encoding.t() | [Encoding.t()], [cast_property_to_value_option()] ) :: {:ok, term()} | {:error, {atom(), term()}} | {:error, term()} def cast_property_to_value(objectid, property_identifier, value, opts \\ []) def cast_property_to_value( %ObjectIdentifier{type: type_mod} = _object_id, property_identifier, value, opts ) when is_atom(property_identifier) and (is_struct(value, Encoding) or is_list(value)) and is_list(opts) do unless Keyword.keyword?(opts) do raise ArgumentError, "cast_property_to_value/4 expected a keyword list, got: #{inspect(opts)}" end case get_object_type_mappings()[type_mod] do nil -> {:error, :unsupported_object_type} object_mod -> unless_env(:prod, do: Code.ensure_loaded!(object_mod)) process_make_property(object_mod, property_identifier, value, allow_partial: get_bool_opts(opts, :allow_partial, get_fa_str(), false) ) end end @doc """ Create an object from a map of properties. This function is used for remote objects, not for local objects. This function invokes the object module's `create` function. Note: In prod environment, required modules are not explicitely loaded. The following options are available: - `allow_unknown_properties: boolean()` - Optional. Allows unknown property identifiers - which means we have no validation (defaults to `false`). - `ignore_unknown_properties: boolean()` - Optional. Ignores properties the object module doesn't support (defaults to `false`). - `remote_device_id: non_neg_integer()` - Optional. Adds the remote BACnet device ID to the object (ID is used for trend logging). - `revision: Constants.protocol_revision()` - Optional. The BACnet protocol revision to check the properties against. - `object_opts: Keyword.t()` - Optional. All other object creation options to pass to the `create` function. """ @spec cast_properties_to_object( BACnet.Protocol.ObjectIdentifier.t(), %{optional(Constants.property_identifier() | atom() | non_neg_integer()) => term()}, [cast_properties_to_object_option()] ) :: {:ok, bacnet_object()} | {:error, {atom(), term()}} | {:error, term()} def cast_properties_to_object(object_id, properties, opts) def cast_properties_to_object( %ObjectIdentifier{type: type_mod} = object_id, %{} = properties, opts ) when is_list(opts) do unless Keyword.keyword?(opts) do raise ArgumentError, "cast_properties_to_object/3 expected a keyword list, got: #{inspect(opts)}" end object_opts = Keyword.get(opts, :object_opts, []) unless Keyword.keyword?(object_opts) do raise ArgumentError, "cast_properties_to_object/3 expected a keyword list " <> "for opts[:object_opts], got: #{inspect(object_opts)}" end case get_object_type_mappings()[type_mod] do nil -> {:error, :unsupported_object_type} object_mod -> unless_env(:prod, do: Code.ensure_loaded!(object_mod)) case Map.pop(properties, Constants.macro_assert_name(:property_identifier, :object_name)) do {nil, _term} -> {:error, {:missing_property, Constants.macro_assert_name(:property_identifier, :object_name)}} {obj_name, properties} -> # Make sure we remove properties we don't handle like that reduced_properties = Map.drop(properties, [:object_identifier, :object_type]) object_mod.create( object_id.instance, obj_name, reduced_properties, Keyword.merge( object_opts, allow_unknown_properties: !!Keyword.get(opts, :allow_unknown_properties, false), ignore_unknown_properties: get_bool_opts( opts, :ignore_unknown_properties, "cast_properties_to_object/3", false ), intrinsic_reporting: object_mod.supports_intrinsic() and Map.has_key?(properties, :notify_type), remote_object: Keyword.get_lazy(opts, :remote_device_id, fn -> if(object_id.type == :device, do: object_id.instance, else: true) end), revision: Keyword.get( opts, :revision, Constants.macro_by_name(:protocol_revision, :default) ) ) ) end end end @doc """ Casts the properties and its values from `Read-Property-(Multiple-)Ack`s into a map of properties. Note: In prod environment, required modules are not explicitely loaded. The following options are available: - `allow_unknown_properties: boolean() | :no_unpack` - Optional. Allows unknown property identifiers - which means we have no validation (defaults to `false`). - `ignore_array_indexes: boolean()` - Optional. Ignores property array indexes as they are currently not supported (defaults to `false`). - `ignore_invalid_properties: boolean()` - Optional. Ignores invalid properties (defaults to `false`). - `ignore_object_identifier_mismatch: boolean()` - Optional. Ignores mismatches between object identifiers (defaults to `false`). - `ignore_unknown_properties: boolean()` - Optional. Ignores unknown property identifiers (defaults to `false`). """ @spec cast_read_properties_ack( ObjectIdentifier.t(), [ReadPropertyAck.t() | ReadPropertyMultipleAck.t()], [cast_read_properties_ack_option()] ) :: {:ok, %{optional(Constants.property_identifier() | atom() | non_neg_integer()) => term()}} | {:error, {atom(), term()}} | {:error, term()} def cast_read_properties_ack( %ObjectIdentifier{type: type_mod} = obj_id, acks, opts \\ [] ) when is_list(acks) and is_list(opts) do unless Keyword.keyword?(opts) do raise ArgumentError, "cast_read_properties_ack/3 expected a keyword list, got: #{inspect(opts)}" end case get_object_type_mappings()[type_mod] do nil -> {:error, :unsupported_object_type} object_mod -> unless_env(:prod, do: Code.ensure_loaded!(object_mod)) fun_name = get_fa_str() allow_unknown_props = Keyword.get(opts, :allow_unknown_properties, false) ignore_array_indexes = get_bool_opts(opts, :ignore_array_indexes, fun_name, false) ignore_invalid_props = get_bool_opts(opts, :ignore_invalid_properties, fun_name, false) ignore_obj_err = get_bool_opts(opts, :ignore_object_identifier_mismatch, fun_name, false) ignore_unknown_props = get_bool_opts(opts, :ignore_unknown_properties, fun_name, false) internal_opts = %RPATransformOptions{ allow_unknown_properties: allow_unknown_props, ignore_array_indexes: ignore_array_indexes, ignore_invalid_properties: ignore_invalid_props, ignore_object_identifier_mismatch: ignore_obj_err, ignore_unknown_properties: ignore_unknown_props } Enum.reduce_while( acks, {:ok, %{}}, &do_process_read_props_ack(&1, &2, obj_id, object_mod, internal_opts) ) end end @doc """ Casts a property value to application tag `Encoding`. To cast the property from the proper data type, the correct object module needs to be known, which contains that property. The object module will tell us which data type should it be. As such, an object identifier is required. No validation happens on the data. Note: In prod environment, required modules are not explicitely loaded. The following options are available: - `allow_nil: boolean()` - Optional. Allows `nil` values (only useful for present value with write priority). - `allow_partial: boolean()` - Optional. Allows partial values of array or list properties (a single value). """ @spec cast_value_to_property( ObjectIdentifier.t(), Constants.property_identifier(), term() | [term()], [cast_value_to_property_option()] ) :: {:ok, Encoding.t() | [Encoding.t()]} | {:error, {atom(), term()}} | {:error, term()} def cast_value_to_property(objectid, property_identifier, value, opts \\ []) def cast_value_to_property( %ObjectIdentifier{type: type_mod} = _object_id, property_identifier, value, opts ) when is_atom(property_identifier) and is_list(opts) do unless Keyword.keyword?(opts) do raise ArgumentError, "cast_value_to_property/4 expected a keyword list, got: #{inspect(opts)}" end case get_object_type_mappings()[type_mod] do nil -> {:error, :unsupported_object_type} object_mod -> unless_env(:prod, do: Code.ensure_loaded!(object_mod)) if function_exported?(object_mod, :get_properties_type_map, 0) do prop_type_map = object_mod.get_properties_type_map() case Map.fetch(prop_type_map, property_identifier) do {:ok, type} -> do_cast_value_to_property( object_mod, property_identifier, type, value, %{ allow_partial: get_bool_opts(opts, :allow_partial, get_fa_str(), false), allow_nil: get_bool_opts(opts, :allow_nil, get_fa_str(), false), original_property: property_identifier, properties_type_map: prop_type_map } ) :error -> {:error, {:unknown_property, property_identifier}} end else {:error, :invalid_object_module} end end end @doc """ Turns the given object's properties with their values into a keyword list. Only properties in the properties list are taken. The `bacnet_object` contract is enforced. The key `:object_instance` will be converted into `:object_identifier`. The list is sorted in ascending order by the property name. """ @spec to_list(bacnet_object()) :: [{Constants.property_identifier(), term()}] def to_list(%_bac{} = object) when is_object(object) do object |> to_map() |> Map.to_list() |> Enum.sort_by(&elem(&1, 0), :asc) end @doc """ Turns the given object's properties with their values into a map. Only properties in the properties list are taken. The `bacnet_object` contract is enforced. The key `:object_instance` will be converted into `:object_identifier`. """ @spec to_map(bacnet_object()) :: %{optional(Constants.property_identifier()) => term()} def to_map(%_bac{_metadata: %{properties_list: props}} = object) when is_object(object) do object |> Map.take(props) |> Map.drop([:_metadata, :object_instance]) |> Map.put(:object_identifier, get_object_identifier(object)) end @spec get_bool_opts(Keyword.t(), atom(), String.t(), boolean()) :: boolean() | no_return() defp get_bool_opts(opts, key, function_name, default) when is_list(opts) and is_atom(key) and is_binary(function_name) and is_boolean(default) do value = Keyword.get(opts, key, default) unless is_boolean(value) do raise ArgumentError, "#{function_name} expected `#{key}` to be a boolean, got: #{inspect(value)}" end value end @spec do_process_read_props_ack( ReadPropertyAck.t() | ReadPropertyMultipleAck.t(), {:ok, map()}, ObjectIdentifier.t(), module(), RPATransformOptions.t() ) :: {:cont, {:ok, map()}} | {:halt, term()} defp do_process_read_props_ack( %ReadPropertyAck{} = ack, {:ok, acc}, obj_id, object_mod, internal_opts ) do case process_make_property_from_read_prop_ack( obj_id, object_mod, ack, internal_opts ) do {:ok, props} -> {:cont, {:ok, Map.merge(acc, props)}} {:error, _err} = err -> {:halt, err} end end defp do_process_read_props_ack( %ReadPropertyMultipleAck{} = ack, {:ok, acc}, obj_id, object_mod, internal_opts ) do case process_make_property_from_read_prop_multiple_ack( obj_id, object_mod, ack, internal_opts ) do {:ok, props} -> {:cont, {:ok, Map.merge(acc, props)}} {:error, _err} = err -> {:halt, err} end end defp do_process_read_props_ack(_else, _acc, _obj_id, _object_mod, _internal_opts), do: {:halt, {:error, :invalid_ack}} @spec process_make_property_from_read_prop_ack( ObjectIdentifier.t(), module(), ReadPropertyAck.t(), RPATransformOptions.t() ) :: {:ok, map()} | {:error, term()} defp process_make_property_from_read_prop_ack(object_id, object_mod, ack, opts) defp process_make_property_from_read_prop_ack( %ObjectIdentifier{} = object_id, _object_mod, %ReadPropertyAck{object_identifier: object_id2} = _ack, %RPATransformOptions{ignore_object_identifier_mismatch: ignore} ) when object_id != object_id2 do if ignore do {:ok, %{}} else {:error, :object_identifier_mismatch} end end # Ignore specific set of property identifiers (we don't need them) defp process_make_property_from_read_prop_ack( _object_id, _object_mod, %ReadPropertyAck{property_identifier: id} = _ack, _opts ) when id in [:object_type] do {:ok, %{}} end defp process_make_property_from_read_prop_ack( %ObjectIdentifier{} = _object_id, object_mod, %ReadPropertyAck{property_array_index: nil} = ack, %RPATransformOptions{ allow_unknown_properties: allow_unknown_properties, ignore_invalid_properties: ignore_invalid_properties, ignore_unknown_properties: ignore_unknown_properties } = _opts ) do case process_make_property( object_mod, ack.property_identifier, ack.property_value, allow_unknown_properties: allow_unknown_properties ) do {:ok, value} -> {:ok, %{ack.property_identifier => value}} {:error, {:invalid_property_value, _prop}} when ignore_invalid_properties -> {:cont, {:ok, %{}}} {:error, {:unknown_property, _prop}} when allow_unknown_properties != false -> {:cont, {:ok, %{ ack.property_identifier => unpack_unknown_properties_if_primitive(ack.property_value, allow_unknown_properties) }}} {:error, {:unknown_property, _prop}} when ignore_unknown_properties -> {:cont, {:ok, %{}}} {:error, _err} = err -> {:halt, err} end end defp process_make_property_from_read_prop_ack( _object_id, _object_mod, _ack, %RPATransformOptions{ ignore_array_indexes: true } ), do: {:ok, %{}} defp process_make_property_from_read_prop_ack(_object_id, _object_mod, _ack, _opts), do: {:error, :property_array_index_not_supported} @spec process_make_property_from_read_prop_multiple_ack( ObjectIdentifier.t(), module(), ReadPropertyMultipleAck.t(), RPATransformOptions.t() ) :: {:ok, map()} | {:error, term()} defp process_make_property_from_read_prop_multiple_ack(object_id, object_mod, ack, opts) defp process_make_property_from_read_prop_multiple_ack( %ObjectIdentifier{} = object_id, object_mod, %ReadPropertyMultipleAck{} = ack, %RPATransformOptions{ allow_unknown_properties: allow_unknown_properties, ignore_array_indexes: ignore_array_indexes, ignore_invalid_properties: ignore_invalid_properties, ignore_object_identifier_mismatch: ignore_object_identifier_mismatch, ignore_unknown_properties: ignore_unknown_properties } = _opts ) do Enum.reduce_while(ack.results, {:ok, %{}}, fn %ReadAccessResult{object_identifier: r_object_id} = results, {:ok, acc} when ignore_object_identifier_mismatch or r_object_id == object_id -> res = Enum.reduce_while(results.results, {:ok, acc}, fn # Ignore errors or nil values %ReadAccessResult.ReadResult{error: err, property_value: val}, acc when err != nil or val == nil -> {:cont, acc} %ReadAccessResult.ReadResult{property_identifier: id} = result, {:ok, acc} when not is_atom(id) -> cond do allow_unknown_properties != false -> {:cont, {:ok, Map.put( acc, id, unpack_unknown_properties_if_primitive( result.property_value, allow_unknown_properties ) )}} ignore_unknown_properties -> {:cont, {:ok, acc}} true -> {:halt, {:error, {:unknown_property, id}}} end %ReadAccessResult.ReadResult{property_identifier: id}, acc when id in [:object_type] -> {:cont, acc} %ReadAccessResult.ReadResult{property_array_index: arr} = result, {:ok, acc} when arr == nil or ignore_array_indexes -> case process_make_property( object_mod, result.property_identifier, result.property_value, allow_unknown_properties: allow_unknown_properties ) do {:ok, value} -> {:cont, {:ok, Map.put(acc, result.property_identifier, value)}} {:error, {:invalid_property_value, _prop}} when ignore_invalid_properties -> {:cont, {:ok, acc}} {:error, {:unknown_property, _prop}} when ignore_unknown_properties -> {:cont, {:ok, acc}} {:error, _err} = err -> {:halt, err} end _results, {:ok, _acc} -> {:halt, {:error, :property_array_index_not_supported_1}} end) case res do {:ok, _val} = val -> {:cont, val} term -> {:halt, term} end _results, _acc -> {:halt, {:error, :object_identifier_mismatch}} end) end @spec process_make_property( module(), Constants.property_identifier(), Encoding.t() | [Encoding.t()], Keyword.t() ) :: {:ok, term()} | {:error, term()} defp process_make_property(object_mod, property_identifier, value, opts) # Handle this property independently from the object modules as they are not declaring it (we can just return the value as-is) defp process_make_property( _object_mod, Constants.macro_assert_name(:property_identifier, :object_identifier), %Encoding{value: %ObjectIdentifier{} = value}, _opts ) do {:ok, value} end defp process_make_property(object_mod, property_identifier, value, opts) do if function_exported?(object_mod, :get_properties_type_map, 0) do prop_type_map = object_mod.get_properties_type_map() case Map.fetch(prop_type_map, property_identifier) do {:ok, type} -> allow_partial = opts[:allow_partial] || false annotations = object_mod.get_annotation(property_identifier) decoder = annotations[:decoder] decoder_result = cond do decoder == nil -> try do # cast_value_to_type( # type |> IO.inspect(label: "type"), # property_identifier, # value |> IO.inspect(label: "value"), # %{allow_partial: allow_partial, properties_type_map: prop_type_map} # ) |> IO.inspect(label: "prop #{property_identifier}") cast_value_to_type( type, property_identifier, value, %{ allow_partial: allow_partial, object_mod: object_mod, properties_type_map: prop_type_map } ) rescue e -> {:error, {:exception_during_casting, e, __STACKTRACE__}} end is_list(value) -> res = Enum.reduce_while(value, {:ok, []}, fn val, {:ok, acc} -> case decode_property_value(type, decoder, property_identifier, val) do {:ok, val} -> {:cont, {:ok, [val | acc]}} term -> {:halt, term} end end) case res do {:ok, list} -> {:ok, Enum.reverse(list)} term -> term end true -> decode_property_value(type, decoder, property_identifier, value) end with {:ok, decoder_value} <- decoder_result do # If partials allowed and the value is not a list, extract the subtype check_type = if allow_partial and not is_list(value) do case type do {:array, subtype} -> subtype {:array, subtype, _size} -> subtype {:list, subtype} -> subtype {:struct, PriorityArray} -> {:type_list, [Map.fetch!(prop_type_map, :present_value), {:literal, nil}]} term -> term end else type end if Internal.check_type(check_type, decoder_value) do {:ok, decoder_value} else {:error, {:invalid_property_value, {property_identifier, decoder_value}}} end else {:error, :invalid_tags} -> {:error, {:invalid_tags, {property_identifier, value}}} {:error, _err} = err -> err end :error -> if allow = opts[:allow_unknown_properties] do {:ok, unpack_unknown_properties_if_primitive(value, allow)} else {:error, {:unknown_property, property_identifier}} end end else {:error, :invalid_object_module} end end @spec decode_property_value( Internal.typechecker_types(), (any() -> any()), Constants.property_identifier(), Encoding.t() | [Encoding.t()] ) :: {:ok, term()} | {:error, term()} defp decode_property_value( _type, decoder, _property_identifier, %Encoding{} = value ) when is_function(decoder, 1) do try do case decoder.(value) do {:ok, _val} = val -> val {:error, _err} = err -> err term -> {:ok, term} end rescue e -> {:error, {:exception_during_decoding, e, __STACKTRACE__}} end end @spec do_cast_value_to_property( module(), Constants.property_identifier(), Internal.typechecker_types(), term() | [term()], map() ) :: {:ok, Encoding.t() | [Encoding.t()]} | {:error, term()} defp do_cast_value_to_property(object_mod, property_identifier, type, value, opts) do annotations = object_mod.get_annotation(property_identifier) encoder = annotations[:encoder] encode_as = annotations[:encode_as] encoder_result = cond do encoder == nil && encode_as -> try do cast_value_to_encoding( encode_as, property_identifier, value, opts ) rescue e -> {:error, {:exception_during_casting, e, __STACKTRACE__}} end encoder == nil -> try do cast_value_to_encoding( type, property_identifier, value, opts ) rescue e -> {:error, {:exception_during_casting, e, __STACKTRACE__}} end is_list(value) -> res = Enum.reduce_while(value, {:ok, []}, fn val, {:ok, acc} -> case encode_property_value(type, encoder, property_identifier, val) do {:ok, val} -> {:cont, {:ok, [val | acc]}} term -> {:halt, term} end end) case res do {:ok, list} -> {:ok, Enum.reverse(list)} term -> term end true -> encode_property_value(type, encoder, property_identifier, value) end # Assert the resulting item is an Encoding struct (or a list of), # so we do post-processing here with {:ok, result} <- encoder_result do cond do is_list(result) -> with {:ok, list} <- Enum.reduce_while(result, {:ok, []}, fn %Encoding{} = item, {:ok, acc} -> {:cont, {:ok, [item | acc]}} item, {:ok, acc} -> case Encoding.create(item) do {:ok, enc} -> {:cont, {:ok, [enc | acc]}} term -> {:halt, term} end end) do {:ok, Enum.reverse(list)} end is_struct(result, Encoding) -> encoder_result true -> Encoding.create(result) end end end @spec cast_value_to_type_manual( Internal.typechecker_types(), Constants.property_identifier(), Encoding.t() | [Encoding.t()], map() ) :: {:ok, term()} | {:error, term()} defp cast_value_to_type_manual(type, _property, value, _opts) when is_list(value) do tags = Enum.map(value, &Encoding.to_encoding!/1) {is_array, mod, arr_size, unwrap} = case type do {:array, {:struct, mod}} -> {true, mod, false, false} {:array, {:struct, mod}, size} -> {true, mod, size, false} {:list, {:struct, mod}} -> {false, mod, false, false} {:struct, mod} -> {false, mod, false, true} end res = Enum.reduce_while(1..1_000_000, {:ok, {[], tags}}, fn _iter, {:ok, {acc, tags}} -> case mod.parse(tags) do {:ok, {struc, []}} -> {:halt, {:ok, {[struc | acc], []}}} {:ok, {struc, rest}} -> {:cont, {:ok, {[struc | acc], rest}}} term -> {:halt, term} end end) case res do {:ok, {[item], _rest}} when unwrap -> {:ok, item} {:ok, {list, _rest}} -> new_list = Enum.reverse(list) # Do post-processing here (check if needs to be array and do validation) if is_array do cond do arr_size == false -> {:ok, BACnetArray.from_list(new_list)} # Force enumeration, as count_until/2 for lists uses length/1 (whole list enumeration!) Enum.count_until(new_list, fn _val -> true end, arr_size + 1) == arr_size -> {:ok, BACnetArray.from_list(new_list, true)} true -> {:error, :bacnet_array_size_mismatch} end else {:ok, new_list} end term -> term end end # Handle these properties in a special way, so it works # This is because the property_value response contains multiple entries (a list), # so each item gets splitted into an Encoding struct, so we need to revert this here # # The process that handles these Read-Property(-Multiple)-Ack responses has no knowledge # about the context, so we could also add logic to the Ack modules to handle some # properties in a special way # (i.e. chunk in three items instead - however the Encoding struct can't handle lists) @manual_property_struct_decoding_list [ :active_cov_subscriptions, :device_address_binding, :exception_schedule, :list_of_group_members, :list_of_object_property_references, :log_buffer, :manual_slave_address_binding, :recipient_list, :slave_address_binding, :time_synchronization_recipients ] @spec cast_value_to_type( Internal.typechecker_types(), Constants.property_identifier(), Encoding.t() | [Encoding.t()], map() ) :: {:ok, term()} | {:error, term()} defp cast_value_to_type(type, property, value, opts) defp cast_value_to_type(type, property, [%Encoding{} | _tl] = value, opts) when property in @manual_property_struct_decoding_list do cast_value_to_type_manual(type, property, value, opts) end # The Group object's present value is a list of ReadAccessResult structs, so we need to handle this differently, too defp cast_value_to_type( type, :present_value = property, [%Encoding{} | _tl] = value, %{object_mod: ObjectTypes.Group} = opts ) do cast_value_to_type_manual(type, property, value, opts) end # Handle the priority array directly (partial response) defp cast_value_to_type( {:struct, PriorityArray}, :priority_array, %Encoding{} = value, %{allow_partial: true} = opts ) do cast_value_to_type( get_in(opts, [:properties_type_map, :present_value]), :present_value, value, opts ) end defp cast_value_to_type( {:struct, PriorityArray}, :priority_array, %Encoding{} = value, %{allow_partial: false} = _opts ) do {:error, {:invalid_property_value, {:priority_array, value}}} end # Handle the priority array directly # and decode enumerated values for boolean present value defp cast_value_to_type({:struct, PriorityArray}, :priority_array, value, opts) when is_list(value) do pv_type = get_in(opts, [:properties_type_map, :present_value]) is_boolean = pv_type == :boolean raw_values = Enum.map(value, fn %Encoding{type: :enumerated, value: value} when is_boolean and value in [0, 1] -> value == 1 %Encoding{} = encoding -> encoding.value term -> term end) # Count the elements and make sure we have 16 elements if Enum.count_until(raw_values, fn _val -> true end, 17) == 16 or Enum.all?(raw_values, &(&1 == nil or Internal.check_type(pv_type, &1))) do {:ok, PriorityArray.from_list(raw_values)} else {:error, {:invalid_property_value, {:priority_array, value}}} end end # Handle partial property value here (partial as we only read a specific index of an array and not the whole array) defp cast_value_to_type({:array, subtype}, property, value, %{allow_partial: true} = opts) when not is_list(value) do cast_value_to_type(subtype, property, value, %{opts | allow_partial: false}) end # Handle partial property value here for sized arrays defp cast_value_to_type( {:array, subtype, _size}, property, value, %{allow_partial: true} = opts ) when not is_list(value) do cast_value_to_type(subtype, property, value, %{opts | allow_partial: false}) end # Handle array struct subtypes differently defp cast_value_to_type({:array, {:struct, _sub} = subtype}, property, value, opts) when is_list(value) do with {:ok, values} <- Enum.reduce_while(value, {:ok, []}, fn item, {:ok, acc} -> case cast_value_to_type(subtype, property, item, opts) do {:ok, value} -> {:cont, {:ok, [value | acc]}} term -> {:halt, term} end end) do {:ok, BACnetArray.from_list(Enum.reverse(values))} else {:error, _err} = err -> err end end defp cast_value_to_type({:array, _subtype}, _property, value, _opts) when is_list(value) do {:ok, BACnetArray.from_list(Enum.map(value, & &1.value))} end # Handle array struct subtypes differently defp cast_value_to_type({:array, {:struct, _sub} = subtype, size}, property, value, opts) when is_integer(size) and is_list(value) do if Enum.count_until(value, size + 1) == size do with {:ok, values} <- Enum.reduce_while(value, {:ok, []}, fn item, {:ok, acc} -> case cast_value_to_type(subtype, property, item, opts) do {:ok, value} -> {:cont, {:ok, [value | acc]}} term -> {:halt, term} end end) do {:ok, BACnetArray.from_list(Enum.reverse(values), true)} else {:error, _err} = err -> err end else {:error, :bacnet_array_size_mismatch} end end defp cast_value_to_type({:array, _subtype, size}, property, value, _opts) when is_integer(size) and is_list(value) do if Enum.count_until(value, size + 1) == size do {:ok, BACnetArray.from_list(Enum.map(value, & &1.value), true)} else {:error, {:bacnet_array_size_mismatch, property}} end end defp cast_value_to_type({:array, _subtype, _size}, _property, value, _opts) when is_list(value) do {:ok, BACnetArray.from_list(Enum.map(value, & &1.value), true)} end # Handle boolean casts if value == 0 or == 1 directly (i.e. boolean properties are ENUMERATED) defp cast_value_to_type(:boolean, _property, %Encoding{type: :enumerated, value: value}, _opts) when value in [0, 1] do {:ok, value == 1} end defp cast_value_to_type({:constant, subtype}, property, %Encoding{value: value}, _opts) do case Constants.by_value(subtype, value) do {:ok, _val} = val -> val :error -> {:error, {:invalid_property_value, {property, value}}} end end # Handle partial property value here (partial as we only read a specific index of an array and not the whole array) defp cast_value_to_type({:list, subtype}, property, value, %{allow_partial: true} = opts) when not is_list(value) do cast_value_to_type(subtype, property, value, %{opts | allow_partial: false}) end defp cast_value_to_type({:list, subtype}, property, value, opts) do with {:ok, values} <- Enum.reduce_while(List.wrap(value), {:ok, []}, fn item, {:ok, acc} -> case cast_value_to_type(subtype, property, item, opts) do {:ok, val} -> {:cont, {:ok, [val | acc]}} term -> {:halt, term} end end) do {:ok, Enum.reverse(values)} else {:error, _err} = err -> err end end defp cast_value_to_type({:struct, mod}, _property, value, _opts) do unless_env(:prod, do: Code.ensure_loaded!(mod)) cast_value_struct_to_type(mod, value) end defp cast_value_to_type({:type_list, subtypes}, property, value, opts) when is_list(subtypes) do Enum.reduce_while(subtypes, {:error, {:invalid_property_value, {property, value}}}, fn subtype, {:error, _term} -> case cast_value_to_type(subtype, property, value, opts) do {:ok, val} -> {:halt, {:ok, val}} term -> {:cont, term} end _subtype, acc -> {:halt, acc} end) end defp cast_value_to_type(_type, _property, %Encoding{value: value}, _opts), do: {:ok, value} @compile {:inline, cast_value_struct_to_type: 2} @spec cast_value_struct_to_type(module(), Encoding.t() | [Encoding.t()]) :: {:ok, term()} | {:error, term()} defp cast_value_struct_to_type(mod, raw_value) defp cast_value_struct_to_type(Encoding, raw_value) do {:ok, raw_value} end defp cast_value_struct_to_type(mod, raw_value) do cond do function_exported?(mod, :from_app_encoding, 1) -> mod.from_app_encoding(raw_value) match?(%{type: :bitstring}, raw_value) and function_exported?(mod, :from_bitstring, 1) -> {:ok, mod.from_bitstring(raw_value.value)} function_exported?(mod, :parse, 1) -> raw_values = if is_list(raw_value) do Enum.map(raw_value, &Encoding.to_encoding!/1) else List.wrap(Encoding.to_encoding!(raw_value)) end case mod.parse(raw_values) do {:ok, {val, _rest}} -> {:ok, val} term -> term end true -> {:error, {:missing_parse_fun, mod}} end end @spec encode_property_value( Internal.typechecker_types(), (any() -> any()), Constants.property_identifier(), term() ) :: {:ok, Encoding.t() | [Encoding.t()] | term()} | {:error, term()} defp encode_property_value( _type, encoder, _property_identifier, value ) when is_function(encoder, 1) do try do case encoder.(value) do {:ok, _val} = val -> val {:error, _err} = err -> err term -> {:ok, term} end rescue e -> {:error, {:exception_during_encoding, e, __STACKTRACE__}} end end @spec cast_value_to_encoding( Internal.typechecker_types(), Constants.property_identifier(), term(), map() ) :: {:ok, Encoding.t() | [Encoding.t()] | term()} | {:error, term()} defp cast_value_to_encoding(type, property, value, opts) # Handle the priority array directly # and encode enumerated values for boolean present value defp cast_value_to_encoding( {:struct, PriorityArray}, :priority_array, %PriorityArray{} = value, opts ) do pv_type = get_in(opts, [:properties_type_map, :present_value]) is_boolean = pv_type == :boolean sub_opts = %{opts | allow_nil: true} with {:ok, values} <- Enum.reduce_while(PriorityArray.to_list(value), {:ok, []}, fn value, {:ok, acc} when is_boolean(value) and is_boolean -> {:cont, {:ok, [{:enumerated, if(value, do: 1, else: 0)} | acc]}} value, {:ok, acc} -> case cast_value_to_encoding(pv_type, :present_value, value, sub_opts) do {:ok, enc} -> {:cont, {:ok, [enc | acc]}} term -> {:halt, term} end end) do {:ok, Enum.reverse(values)} else {:error, _err} = err -> err end end # Handle partial property value here (partial as we only read a specific index of an array and not the whole array) defp cast_value_to_encoding({:array, subtype}, property, value, %{allow_partial: true} = opts) when not is_struct(value, BACnetArray) do cast_value_to_encoding(subtype, property, value, %{opts | allow_partial: false}) end defp cast_value_to_encoding( {:array, _subtype}, property, value, %{allow_partial: false} = _opts ) when not is_struct(value, BACnetArray) do {:error, {:invalid_property_value, {property, value}}} end # Handle partial property value here for sized arrays defp cast_value_to_encoding( {:array, subtype, _size}, property, value, %{allow_partial: true} = opts ) when not is_struct(value, BACnetArray) do cast_value_to_encoding(subtype, property, value, %{opts | allow_partial: false}) end defp cast_value_to_encoding( {:array, _subtype, _size}, property, value, %{allow_partial: false} = _opts ) when not is_struct(value, BACnetArray) do {:error, {:invalid_property_value, {property, value}}} end defp cast_value_to_encoding({:array, subtype}, property, %BACnetArray{} = array, opts) do with {:ok, values} <- BACnetArray.reduce_while(array, {:ok, []}, fn item, {:ok, acc} -> case cast_value_to_encoding(subtype, property, item, opts) do {:ok, value} -> {:cont, {:ok, [value | acc]}} term -> {:halt, term} end end) do {:ok, Enum.reverse(values)} else {:error, _err} = err -> err end end defp cast_value_to_encoding({:array, _subtype}, property, value, _opts) do {:error, {:invalid_property_value, {property, value}}} end defp cast_value_to_encoding({:array, subtype, size}, property, %BACnetArray{} = array, opts) when is_integer(size) do if BACnetArray.get_size(array) == size do cast_value_to_encoding({:array, subtype}, property, array, opts) else {:error, {:bacnet_array_size_mismatch, property}} end end defp cast_value_to_encoding({:array, _subtype, _size}, property, value, _opts) do {:error, {:invalid_property_value, {property, value}}} end defp cast_value_to_encoding({:constant, subtype}, property, value, _opts) when is_atom(value) do case Constants.by_name(subtype, value) do {:ok, val} -> {:ok, {:enumerated, val}} :error -> {:error, {:invalid_property_value, {property, value}}} end end defp cast_value_to_encoding({:constant, _subtype}, _property, value, _opts) when is_integer(value) and value >= 0 do {:ok, {:enumerated, value}} end # Handle partial property value here (partial as we only read a specific index of an array and not the whole array) defp cast_value_to_encoding({:list, subtype}, property, value, %{allow_partial: true} = opts) when not is_list(value) do cast_value_to_encoding(subtype, property, value, %{opts | allow_partial: false}) end defp cast_value_to_encoding({:list, _subtype}, property, value, %{allow_partial: false} = _opts) when not is_list(value) do {:error, {:invalid_property_value, {property, value}}} end defp cast_value_to_encoding({:list, subtype}, property, value, opts) do with {:ok, values} <- Enum.reduce_while(List.wrap(value), {:ok, []}, fn item, {:ok, acc} -> case cast_value_to_encoding(subtype, property, item, opts) do {:ok, val} -> {:cont, {:ok, [val | acc]}} term -> {:halt, term} end end) do new_values = values |> Enum.reverse() |> List.flatten() {:ok, new_values} else {:error, _err} = err -> err end end defp cast_value_to_encoding({:struct, mod}, property, value, _opts) do unless_env(:prod, do: Code.ensure_loaded!(mod)) try do is_struct(value, mod) and (not function_exported?(mod, :valid?, 1) or mod.valid?(value)) catch _kind, _err -> {:error, {:invalid_property_value, {property, value}}} else true -> cast_value_struct_to_encoding(mod, value) false -> {:error, {:invalid_property_value, {property, value}}} end end defp cast_value_to_encoding({:type_list, subtypes}, property, value, opts) when is_list(subtypes) do Enum.reduce_while(subtypes, {:error, {:invalid_property_value, {property, value}}}, fn subtype, {:error, _term} -> case cast_value_to_type(subtype, property, value, opts) do {:ok, val} -> {:halt, {:ok, val}} term -> {:cont, term} end _subtype, acc -> {:halt, acc} end) end # Handle basic types here defp cast_value_to_encoding({:literal, nil}, _property, nil, _opts) do {:ok, {:null, nil}} end defp cast_value_to_encoding(:boolean, _property, value, _opts) when is_boolean(value) do {:ok, {:boolean, value}} end defp cast_value_to_encoding(:enumerated, _property, value, _opts) when is_boolean(value) do {:ok, {:enumerated, if(value, do: 1, else: 0)}} end defp cast_value_to_encoding(:string, property, value, _opts) when is_binary(value) do if String.valid?(value) do {:ok, {:character_string, value}} else {:error, {:invalid_string_value, property}} end end defp cast_value_to_encoding(:octet_string, _property, value, _opts) when is_binary(value) do {:ok, {:octet_string, value}} end defp cast_value_to_encoding(:signed_integer, _property, value, _opts) when is_integer(value) do {:ok, {:signed_integer, value}} end defp cast_value_to_encoding(:unsigned_integer, _property, value, _opts) when is_integer(value) and value >= 0 do {:ok, {:unsigned_integer, value}} end defp cast_value_to_encoding(:real, _property, value, _opts) when is_float(value) do {:ok, {:real, value}} end defp cast_value_to_encoding(:real, _property, value, _opts) when value in [:NaN, :inf, :infn] do {:ok, {:real, value}} end defp cast_value_to_encoding(:real, _property, value, _opts) when is_integer(value) do {:ok, {:real, value * 1.0}} end defp cast_value_to_encoding(:double, _property, value, _opts) when is_float(value) do {:ok, {:double, value}} end defp cast_value_to_encoding(:double, _property, value, _opts) when value in [:NaN, :inf, :infn] do {:ok, {:double, value}} end defp cast_value_to_encoding(:double, _property, value, _opts) when is_integer(value) do {:ok, {:double, value * 1.0}} end defp cast_value_to_encoding(:bitstring, _property, value, _opts) when is_tuple(value) do {:ok, {:bitstring, value}} end defp cast_value_to_encoding({:in_range, x, y}, _property, value, _opts) when is_integer(value) and value >= x and value <= y and x < 0 do {:ok, {:signed_integer, value}} end defp cast_value_to_encoding({:in_range, x, y}, _property, value, _opts) when is_integer(value) and value >= x and value <= y do {:ok, {:unsigned_integer, value}} end defp cast_value_to_encoding(_type, _property, nil, %{allow_nil: true} = _opts), do: {:ok, {:null, nil}} defp cast_value_to_encoding(_type, property, value, opts), do: {:error, {:invalid_property_value, {opts[:original_property] || property, value}}} @compile {:inline, cast_value_struct_to_encoding: 2} @spec cast_value_struct_to_encoding(module(), term()) :: {:ok, Encoding.t() | [Encoding.t()] | term()} | {:error, term()} defp cast_value_struct_to_encoding(mod, value) defp cast_value_struct_to_encoding(_mod, %Encoding{} = value) do {:ok, value} end defp cast_value_struct_to_encoding(mod, value) do cond do function_exported?(mod, :to_app_encoding, 1) -> mod.to_app_encoding(value) function_exported?(mod, :to_bitstring, 1) -> {:ok, mod.to_bitstring(value)} function_exported?(mod, :encode, 1) -> mod.encode(value) true -> {:error, {:missing_encode_fun, mod}} end end @spec unpack_unknown_properties_if_primitive(Encoding.t() | term(), boolean() | :no_unpack) :: term() defp unpack_unknown_properties_if_primitive(value, opt) defp unpack_unknown_properties_if_primitive(value, :no_unpack), do: value defp unpack_unknown_properties_if_primitive( %Encoding{encoding: :primitive, type: _type, value: value} = _enc, _opt ), # when type not in [:real, :double], do: value defp unpack_unknown_properties_if_primitive(value, _opt), do: value end