defmodule BACnet.Protocol.ObjectsMacro do
@moduledoc """
This is an internal module for defining BACnet objects.
If you're a library user, there's no need for you to ever deal with this module.
If you want to store the BACnet object definition AST as a persistent module
attribute (`:bacobj_ast`) in each BACnet object module, you can set the key `:store_bacobj_ast`
to `true` for the `:bacstack` application. If `:bacstack` is a dependency of your
Mix project, you will need to recompile bacstack with `mix deps.compile bacstack --force`.
The following has to be taken care of when trying decode/encode properties:
- Check the annotations for decoder/encoder functions (single argument - the plain value (no tag encoding))
- Check the annotations for `encode_as` primitive type declaration (i.e. used to declare enumerated booleans)
- Check the properties types map - 99% should be covered by this (1% is covered by annotations)
- Custom decoding/encoding by hand for special properties (not yet supported properties/objects)
"""
alias BACnet.Internal
alias BACnet.Protocol.ApplicationTags.Encoding
alias BACnet.Protocol.BACnetDateTime
alias BACnet.Protocol.BACnetDate
alias BACnet.Protocol.BACnetTime
alias BACnet.Protocol.BACnetTimestamp
alias BACnet.Protocol.Constants
alias BACnet.Protocol.DeviceObjectPropertyRef
alias BACnet.Protocol.EventMessageTexts
alias BACnet.Protocol.EventTransitionBits
alias BACnet.Protocol.ObjectIdentifier
alias BACnet.Protocol.ObjectPropertyRef
alias BACnet.Protocol.PriorityArray
require Constants
# Allow to store the BACnet object definition AST using the application env
@bacobj_store_ast Application.compile_env(:bacstack, :store_bacobj_ast, false)
@required_struct_ast (quote do
field(:object_instance, non_neg_integer(),
required: true,
readonly: true
)
field(:object_name, String.t(), required: true, readonly: true)
end)
@intrinsic_struct_ast (quote do
field(:acked_transitions, EventTransitionBits.t(),
intrinsic: true,
readonly: true
)
field(:event_algorithm_inhibit, boolean(), intrinsic: true)
field(
:event_algorithm_inhibit_ref,
ObjectPropertyRef.t(),
intrinsic: true,
implicit_relationship: :event_algorithm_inhibit
)
field(:event_detection_enable, boolean(), intrinsic: true)
field(:event_enable, EventTransitionBits.t(), intrinsic: true)
field(:event_message_texts, BACnet.Protocol.EventMessageTexts.t(),
intrinsic: true,
readonly: true
)
field(
:event_message_texts_config,
BACnet.Protocol.EventMessageTexts.t(),
intrinsic: true
)
field(:event_timestamps, BACnet.Protocol.EventTimestamps.t(),
intrinsic: true,
readonly: true
)
field(:limit_enable, BACnet.Protocol.LimitEnable.t(), intrinsic: true)
field(:notify_type, Constants.notify_type(), intrinsic: true)
field(:notification_class, non_neg_integer(), intrinsic: true)
field(:time_delay, non_neg_integer(), intrinsic: true)
field(:time_delay_normal, non_neg_integer(), intrinsic: true)
end)
@basic_properties_to_type_map %{
object_name: :string,
description: :string,
event_state: {:constant, :event_state},
status_flags: {:struct, BACnet.Protocol.StatusFlags},
out_of_service: :boolean,
priority_array: {:struct, PriorityArray},
profile_name: :string,
reliability: {:constant, :reliability},
reliability_evaluation_inhibit: :boolean,
update_interval: :unsigned_integer,
units: {:constant, :engineering_unit},
# Intrinsic Reporting (implicitely nil if object not intrinsic)
acked_transitions: {:struct, BACnet.Protocol.EventTransitionBits},
event_algorithm_inhibit: :boolean,
event_algorithm_inhibit_ref: {:struct, ObjectPropertyRef},
event_detection_enable: :boolean,
event_enable: {:struct, BACnet.Protocol.EventTransitionBits},
event_timestamps: {:struct, BACnet.Protocol.EventTimestamps},
event_message_texts: {:struct, BACnet.Protocol.EventMessageTexts},
event_message_texts_config: {:struct, BACnet.Protocol.EventMessageTexts},
limit_enable: {:struct, BACnet.Protocol.LimitEnable},
notification_class: :unsigned_integer,
notify_type: {:constant, :notify_type},
time_delay: :unsigned_integer,
time_delay_normal: :unsigned_integer
}
@protected_properties [
# Internal keys
:_metadata,
:_writable_properties,
:_unknown_properties,
# General properties
:object_identifier,
# :object_instance,
:object_type
# Intrinsic Reporting
# :event_message_texts
]
#### Helpers START ####
@default_bacnet_timestamp %BACnetTimestamp{
type: :datetime,
time: nil,
sequence_number: nil,
datetime: %BACnetDateTime{
date: %BACnetDate{
year: :unspecified,
month: :unspecified,
day: :unspecified,
weekday: :unspecified
},
time: %BACnetTime{
hour: :unspecified,
minute: :unspecified,
second: :unspecified,
hundredth: :unspecified
}
}
}
@typedoc """
BACnet object property decoder function for annotation `decoder`.
Used by `BACnet.Protocol.ObjectsUtility.cast_property_to_value/4`.
`term()` as return is the same as `{:ok, term()}`.
"""
@type decoder :: (Encoding.t() | [Encoding.t()] -> {:ok, term()} | {:error, term()} | term())
@typedoc """
BACnet object property encoder function for annotation `encoder`.
Used by `BACnet.Protocol.ObjectsUtility.cast_value_to_property/4`.
"""
@type encoder :: (term() -> {:ok, Encoding.t() | [Encoding.t()] | term()} | {:error, term()})
@typedoc """
Supported values for annotation `required_when` and `only_when`.
`operator` must be a function from the module `Kernel`.
"""
@type required_only_when ::
{:property, name :: Constants.property_identifier()}
| {:property, name :: Constants.property_identifier(), value :: term()}
| {:property, name :: Constants.property_identifier(), operator :: atom(),
value :: term()}
| {:opts, name :: atom()}
| {:opts, name :: atom(), value :: term()}
| {:opts, name :: atom(), operator :: atom(), value :: term()}
| (properties_so_far :: map() -> boolean())
| (properties_so_far :: map(), metadata :: map() -> boolean())
@doc """
Get a default BACnet DateTime with every field `:unspecified`.
"""
@spec get_default_bacnet_datetime() :: BACnetDateTime.t()
def get_default_bacnet_datetime() do
%BACnetDateTime{
date: %BACnetDate{
year: :unspecified,
month: :unspecified,
day: :unspecified,
weekday: :unspecified
},
time: %BACnetTime{
hour: :unspecified,
minute: :unspecified,
second: :unspecified,
hundredth: :unspecified
}
}
end
@doc """
Get a default BACnet Timestamp with a `DateTime` and every field `:unspecified`.
"""
@spec get_default_bacnet_timestamp() :: BACnetTimestamp.t()
def get_default_bacnet_timestamp() do
@default_bacnet_timestamp
end
@doc """
Get a default BACnet Event Message Texts with default English strings.
"""
@spec get_default_event_message_texts() :: EventMessageTexts.t()
def get_default_event_message_texts() do
%EventMessageTexts{
to_offnormal: "To-OffNormal",
to_fault: "To-Fault",
to_normal: "To-Normal"
}
end
@doc """
Get a default BACnet Event Transition Bits with each bit set to a specific value.
"""
@spec get_default_event_transbits(boolean()) :: EventTransitionBits.t()
def get_default_event_transbits(default_value \\ true) do
%EventTransitionBits{
to_offnormal: default_value,
to_fault: default_value,
to_normal: default_value
}
end
@doc """
Get a default BACnet Device Object Property Reference. References a BI object with the
highest instance number, referencing the highest property identifier.
The highest instance number usually represents an uninitialized property.
"""
@spec get_default_dev_object_ref() :: DeviceObjectPropertyRef.t()
def get_default_dev_object_ref() do
%DeviceObjectPropertyRef{
object_identifier: %ObjectIdentifier{
type: Constants.macro_assert_name(:object_type, :binary_input),
instance: Constants.macro_by_name(:asn1, :max_instance_and_property_id)
},
property_identifier: Constants.macro_by_name(:asn1, :max_instance_and_property_id),
property_array_index: nil,
device_identifier: nil
}
end
@doc """
Get a default BACnet Object Property Reference. References a BI object with the
highest instance number, referencing the highest property identifier.
The highest instance number usually represents an uninitialized property
(such as in the case for `event_algorithm_inhibit_ref`).
"""
@spec get_default_object_ref() :: ObjectPropertyRef.t()
def get_default_object_ref() do
%ObjectPropertyRef{
object_identifier: %ObjectIdentifier{
type: Constants.macro_assert_name(:object_type, :binary_input),
instance: Constants.macro_by_name(:asn1, :max_instance_and_property_id)
},
property_identifier: Constants.macro_by_name(:asn1, :max_instance_and_property_id),
property_array_index: nil
}
end
#### Helpers END ####
@doc """
Inserts an `import` for the `bac_object/2` macro.
"""
defmacro __using__(_opts) do
quote do
import unquote(__MODULE__), only: [bac_object: 2]
require BACnet.Protocol.Constants
end
end
@doc """
Defines a BACnet object.
This macro generates the struct, the necessary functions, helpers, types
and module documentation and inserts these into the AST of the module.
Use `define_bacnet_object/3` to use something that can be used outside of a module
(it produces AST for `bac_object/2`).
### Definition
To use this macro, you need to pass it the BACnet object type (`t:BACnet.Protocol.Constants.object_type/0`) and
the definition of the object in a `do` block.
The definition is built up using the following macros:
- `services/1` - Defines which services are available in keyword notation (services: `intrinsic`).
- `field/3` - Defines each field/property of the object. First the name, then the Elixir typespec,
following options. When writing typespecs, the typespec will be resolved. It must be noted, not
all types are supported (i.e. plain maps). The following options are available:
- `annotation` - Can be used multiple times. Allows to add annotations to the field which can be retrieved
using `get_annotation/1`. The return value is always a list. The given argument must be allowed to be
escaped (such as no evaluated functions).
The given argument should be `{:field_name, value}` or `[field_name: value]`.
The whole annotations list is flattened, to allow keyword based access through `get_annotation/1`.
- `bac_type` - Used to override the inherited type from the typespec,
which is then used for property value typechecks. The value of this field is to be a valid type of
`t:Internal.typechecker_types/0` or
`{:with_validator, type of Internal.typechecker_types/0, validator function quote block or capture}`.
Types given through this option will not be further validated and may lead to runtime errors when
incorrectly used.
- `cov` - Property is part of COV Reporting (the "main" property for reporting changes).
- `default` - Default value for the property. This can be a constant value, function call,
anonymous function or capture expression (arity 0!).
Function calls, definitions and captures are executed at compile time.
- `implicit_relationship` - Implicit relationship between two properties.
The other property gets automatically added, if one of the relationship gets added.
- `init_fun` - Used to initialize the property with an initial value
(only remote function captures with arity 0 allowed). Properties with an `init_fun` get added to the object
as if the property was required and had a default value, if it's not a remote object.
- `intrinsic` - Property is part of Intrinsic Reporting.
- `protected` - The property can not be changed through the functions (i.e. `object_instance` can not be mutated).
- `readonly` - Annotation that the property should only be readonly (write protected from the BACnet side).
- `required` - Required property, must always be present.
- `validator_fun` - Used to verify the property value before inserting.
This function can accept, zero, one or two arguments (the value and the object itself). During object creation,
the function will get a plain map of the currently accumulated properties as object.
The macro will verify the structure. Fields with implicit relationships should always have a default value.
Object creation will otherwise fail.
When creating an object and a required property has no default and no value is given at creation,
object creation will fail.
If an object gets created and only one property gets specified of an implicit relationship, the other
property gets automatically added (which explains why it should have a default value).
An example definition looks like this:
```elixir
bac_object :analog_input do
services(intrinsic: true)
field(:description, String.t()) # Optional property
field(:device_type, String.t()) # Optional property
field(:out_of_service, boolean(), required: true) # Required property, has an implicit default value
field(:present_value, boolean(), required: true, default: false) # Required property, has a default value
end
```
For convenience, the `fetch/2` function will be implemented for use with the `Access` behaviour.
All other `Access` behaviour callbacks/functions will not be implemented.
### Code Generation, Properties and Relationships
The following functions for working with objects get generated:
- `add_property/3`
- `create/4`
- `cov_reporting?/1`
- `get_object_identifier/1`
- `get_properties/1`
- `has_property?/2`
- `intrinsic_reporting?/1`
- `property_writable?/2`
- `remove_property/2`
- `update_property/3`
The `property_writable/2` function should be overridden by modules to set certain
properties writable only during certain conditions, if this is required by the object.
The following functions get generated based on the available properties (priority_array):
- `get_priority_value/1`
- `set_priority/3`
The following helper functions get generated:
- `get_all_properties/0`
- `get_cov_properties/0`
- `get_intrinsic_properties/0`
- `get_optional_properties/0`
- `get_properties_type_map/0`
- `get_protected_properties/0`
- `get_readonly_properties/0`
- `get_required_properties/0`
- `supports_intrinsic/0`
The following types get generated:
- `common_object_opts/0` (for basic BACnet object options)
- `property_name/0`
- `property_update_error/0`
- `t/0`
The following properties have an implicit default value:
- event_state (`:normal`)
- out_of_service (`false`)
- status_flags (all bits `false`)
The following implicit relationships exist and do not need to be manually defined:
- priority_array <-> relinquish_default
The following properties are implicitely protected:
- object_identifier (does not exist on the struct)
- object_type (does not exist on the struct)
- properties_list (does not exist as property on the struct - properties are tracked in metadata)
Both properties which do not exist are inherited from the module. Properties list is tracked internally
and thus protected from mutation through the functions. These properties need to be dynamically
inherited through the device server for the BACnet side to conform to the BACnet standard.
For input objects, the device server needs to implement the present value write protection for when
the object is not out of service. The object itself does not provide such a mechanism as it does
not know from where the write is happening (locally from the device or through BACnet).
The following properties are required for all objects and are automatically defined:
- object_instance (annotated as readonly)
- object_name (annotated as readonly)
For intrinsic objects, the following properties are automatically defined (with a default value):
- acked_transitions (annotated as readonly)
- event_algorithm_inhibit
- event_algorithm_inhibit_ref
- event_detection_enable
- event_enable
- event_message_texts (annotated as readonly)
- event_message_texts_config
- event_timestamps (annotated as readonly)
- limit_enable
- notify_type
- notification_class
- time_delay
- time_delay_normal
For commandable objects (objects with a priority array), the present value property is protected,
unless out of service is active. For the duration of out of service, updates to the present value
using `update_property/3` are allowed. Once out of service is disabled, the present value is once
again protected from updates, as the present value is updated through the relinquish_default and
priority_array properties.
Implementors using this macro can "inhibit" an object and verify or mutate the object,
but also return an error. For that a private function can be overridden. The function is called
whenever `create`, `add_property`, `remove_property` (excluding unknown propertes)
and `update_property` is used.
The following private function can be overriden and used a hook:
```ex
inhibit_object_check(t()) :: {:ok, t()} | {:error, term()}
```
It receives the object struct and should return an ok or error tuple.
### Annotations
Annotations can be used for multiple things. There are some that have a special meaning inside the library.
The following annotations are used currently:
- `decoder: decoder()` - Function used to decode the ASN.1 value to an Elixir value (the typespec).
- `encoder: encoder()` - Function used to encode the Elixir value (the typespec) to an ASN.1 value.
- `only_when: required_only_when()` - See below. Allows a property to "exist" only when the condition is met.
- `required_when: required_only_when()` - See below. Marks a property as required when the condition is met.
The encoder and decoder annotations are used by the `BACnet.Protocol.ObjectsUtility` module to encode and decode properties.
Annotations with the key name `required_when` and `only_when` will be respected, if their value is supported.
`required_when` can be used to conditionally require certain properties.
The following values are supported:
- `{:property, Constants.property_identifier()}` - The given property must be present in the object.
- `{:property, Constants.property_identifier(), value}` - The given property must be present
in the object and have the specified value.
- `{:property, Constants.property_identifier(), operator, value}` - The given property must be present
in the object and have the specified value. The value is compared using the given operator from
the `Kernel` module (must be a function in said module).
- `{:opts, atom()}` - The given option must be present in the object options (given in `create/4`)
and have the value `true`.
- `{:opts, atom(), value}` - The given option must be present in the object options
and have the specified value.
- `{:opts, atom(), operator, value}` - The given option must be present
in the object options and have the specified value. The value is compared using the given
operator from the `Kernel` module (must be a function in said module).
- `(map() -> boolean())` - Function with arity 1, receives the currently
accumulated properties. Returning `true` means the property is required.
- `(map(), map() -> boolean())` - Function with arity 2, receives the currently
accumulated properties and the metadata map. Returning `true` means the property is required.
Other values than the supported values get simply ignored - there's no error or warning.
For example, to have the following field being marked as required (than by default being optional),
it requires the option `:supports_restart` to be `true`:
```elixir
field(
:last_restart_reason,
Constants.restart_reason(),
annotation: [required_when: {:opts, :supports_restart}]
)
```
When instantiating the instance and the said option is given with the value `true`,
then the property is required and must either have a default value or be explicitely given.
If the property does not have a default value or is not given, the instantiation fails with an error.
If the said option is not given (or with any value other than `true`), then the property stays optional.
To have the property only present and can only be instantiated when the property is required,
to meet BACnet requirements to have some properties only present when some condition is met,
the annotation `only_when` is supported. It supports the same values as `required_when`.
That means, the optional property can not be used unless the `only_when` test passes true.
The exception being remote objects, where all optional properties can always be used,
as these our outside of our responsibility.
### Extendibility
Object types can be extended at compile time by the user to provide additional properties.
Use the application `:bacstack` and key `:objects_additional_properties` to provide a map or keyword list,
keyed by the object type as atom, with an AST of additional properties (as if you were to provide them directly).
Make sure that the used property identifiers are already defined (either by the library or at compile time by the user).
Example (`config/config.exs`):
```elixir
config :bacstack, :additional_property_identifiers, loop_enable: 523, loop_mode: 524
config :bacstack, :objects_additional_properties,
loop:
(quote do
field(:loop_enable, boolean(), encode_as: :enumerated)
field(:loop_mode, :bacnet_loop | :plc_loop,
bac_type: {:in_list, [:bacnet_loop, :plc_loop]},
annotation: [
encoder: &{:enumerated, if(&1 == :plc_loop, do: 1, else: 0)},
decoder: &if(&1.value == 1, do: :plc_loop, else: :bacnet_loop)
]
)
end)
```
"""
defmacro bac_object(object_type, definition)
defmacro bac_object(object_type, do: ast) do
object_type = Macro.expand(object_type, __CALLER__)
define_bacnet_object(object_type, ast, __CALLER__)
end
# credo:disable-for-lines:100 Credo.Check.Refactor.CyclomaticComplexity
@doc """
Defines a BACnet object. This function produces AST from the given BACnet object definition.
For a description of what it does, see the `bac_object/2` macro.
"""
@spec define_bacnet_object(Constants.object_type(), Macro.t(), Macro.Env.t()) ::
Macro.t() | no_return()
def define_bacnet_object(object_type, ast, %Macro.Env{} = env) when is_atom(object_type) do
unless Constants.has_by_name(:object_type, object_type) do
raise ArgumentError, "Invalid object type, got: #{inspect(object_type)}"
end
default_revision =
Constants.by_name!(:protocol_revision, Constants.by_name!(:protocol_revision, :default))
additional_fields =
:bacstack
|> Application.get_env(:objects_additional_properties)
|> Access.get(object_type, [])
|> case do
{:__block__, _meta, fields} -> fields
[] -> []
field -> [field]
end
fields_ast =
case ast do
{:__block__, _meta, fields} -> fields ++ additional_fields
field -> [field | additional_fields]
end
req_struct_ast =
case @required_struct_ast do
{:__block__, _meta, fields} -> fields
end
intrins_struct_ast =
case @intrinsic_struct_ast do
{:__block__, _meta, fields} -> fields
end
# Traverse the AST for the first time to find the supported services
supported_services =
fields_ast
|> Enum.find_value(%{}, fn ast ->
case ast do
{:services, _line, _ast} -> get_services_data(ast, env)
_else -> nil
end
end)
|> then(&Map.merge(%{intrinsic: false}, &1))
# Only add the intrinsic fields, if the object supports intrinsic reporting
intrins_fields_ast =
if supported_services.intrinsic do
intrins_struct_ast
else
[]
end
fields_ast = req_struct_ast ++ intrins_fields_ast ++ fields_ast
fields_data =
fields_ast
|> Enum.reduce([], fn ast, acc ->
case ast do
{:field, _line, _ast} -> [get_field_data(ast, env) | acc]
_else -> acc
end
end)
|> Enum.uniq_by(fn %{name: name} -> name end)
struct_fields =
for %{name: name} <- fields_data do
name
end
# Create the t() typespecs
struct_typespecs =
Enum.map(
[
%{name: :_metadata, required: true, typespec: quote(do: internal_metadata())},
%{
name: :_unknown_properties,
required: true,
typespec:
quote(
do: %{
optional(atom() | non_neg_integer()) =>
term()
| BACnet.Protocol.ApplicationTags.Encoding.t()
| [BACnet.Protocol.ApplicationTags.Encoding.t()]
}
)
}
| fields_data
],
fn
%{name: name, typespec: typespec, required: true} ->
{name, typespec}
%{name: name, typespec: typespec} ->
{
name,
{:|, [], [typespec, nil]}
}
end
)
# Create the list for defstruct
struct_deffields =
for %{name: name} <- fields_data do
name
end
# Remove all fields with leading underscore (or fields with a default value)
cleaned_fields =
Enum.reject(struct_fields, &(is_tuple(&1) or String.starts_with?(Atom.to_string(&1), "_")))
# Create typespec for the type property_name
fields =
cleaned_fields
|> Enum.sort(:desc)
|> Enum.reduce(fn field, acc ->
{:|, [], [field, acc]}
end)
# Generate Elixir type for the Present Value property
{pv_ex_type, pv_typespec} =
if :present_value in struct_deffields do
# We do not need to check whether the return value is not nil,
# because we are bound to have the data since the field is present
Enum.find_value(fields_data, nil, fn
%{name: :present_value, bac_type: bac_type, typespec: typespec} ->
{bac_type, typespec}
_term ->
nil
end)
else
{nil, {:term, [], []}}
end
# Create map of property_name to their type
properties_type_map =
@basic_properties_to_type_map
|> Map.merge(Map.new(fields_data, fn %{name: name, bac_type: type} -> {name, type} end))
|> Enum.reject(&String.starts_with?(Atom.to_string(elem(&1, 0)), "_"))
|> Map.new()
# Compile a few information for use in the quote block
protected_properties =
@protected_properties ++
for field <- fields_data, field.protected do
field.name
end
required_properties =
for field <- fields_data, field.required do
field.name
end
# Map of property_name to revision (required properties)
properties_revision_map =
Map.new(
for field <- fields_data, field.required do
{field.name, field.annotations[:revision] || default_revision}
end
)
readonly_properties =
for field <- fields_data, field.readonly do
field.name
end
intrinsic_properties =
fields_data
|> Enum.filter(& &1.intrinsic)
|> Enum.map(& &1.name)
cov_properties =
fields_data
|> Enum.filter(& &1.cov)
|> Enum.map(& &1.name)
implicit_relationships =
Map.new(
get_default_implicit_relationships() ++
(fields_data
|> Enum.reject(&(&1.implicit_relationship == nil))
|> Enum.map(&{&1.name, &1.implicit_relationship}))
)
# Default values for required properties (only fields with default value)
default_properties =
get_default_required_properties() ++
(fields_data
|> Enum.filter(&(&1.required and &1.default != nil))
|> Enum.map(&{&1.name, &1.default}))
# Now add those optional properties that are required for this object type
default_properties =
Enum.reduce(get_default_optional_properties(), default_properties, fn {name, value}, acc ->
if name in required_properties and not Keyword.has_key?(acc, name) do
[{name, value} | acc]
else
acc
end
end)
# Assert now the default_properties does not have any fields with default value == nil
Enum.each(default_properties, fn
{key, nil} -> raise "Invalid default value for struct key \"#{key}\": Value is nil"
{_key, _value} -> :ok
end)
default_properties_all =
Map.new(
Enum.reject(
default_properties ++
get_default_optional_properties() ++
(fields_data
|> Enum.filter(&(not &1.intrinsic and &1.default != nil))
|> Enum.map(&{&1.name, &1.default})),
fn {key, value} -> key not in struct_deffields or is_nil(value) end
)
)
default_intrinsic_properties =
get_default_intrinsic_properties() ++
(fields_data
|> Enum.filter(&(&1.intrinsic and &1.default != nil))
|> Enum.map(&{&1.name, &1.default}))
default_cov_increment =
Enum.find_value(fields_data, nil, fn
%{name: :cov_increment, default: value} -> value
_term -> nil
end)
supports_intrinsic = supported_services.intrinsic
call_intrinsic_on_object_check =
if supports_intrinsic do
quote do
intrinsic_reporting?(object)
end
else
quote do
false
end
end
properties_validators =
Enum.map(fields_data, fn %{name: name, type_validator: fun, validator_fun: val} ->
{name, {fun, val}}
end)
init_fun_map =
fields_data
|> Map.new(fn
%{init_fun: nil} -> {:__drop__, nil}
%{name: name, init_fun: val} -> {name, val}
end)
|> Map.delete(:__drop__)
annotations =
Enum.map(fields_data, fn %{name: name, annotations: anno} ->
{name, anno}
end)
moduledoc = generate_moduledoc(object_type, fields_data)
# credo:disable-for-lines:100 Credo.Check.Refactor.LongQuoteBlocks
quote generated: true, location: :keep do
@moduledoc (case @moduledoc do
nil -> unquote(moduledoc)
false -> false
doc -> doc <> "\n\n" <> unquote(moduledoc)
end)
@type t :: %__MODULE__{unquote_splicing(struct_typespecs)}
defstruct unquote(struct_deffields) ++
[
_metadata: %{
properties_list: [],
revision: Constants.macro_by_name(:protocol_revision, :default),
intrinsic_reporting: false,
remote_object: nil,
physical_input: nil,
other: %{}
},
_unknown_properties: %{}
]
@supported_services unquote(Macro.escape(supported_services))
Module.register_attribute(__MODULE__, :bacnet_object, persist: true)
Module.put_attribute(__MODULE__, :bacnet_object, unquote(object_type))
unquote(
if @bacobj_store_ast do
quote do
Module.register_attribute(__MODULE__, :bacobj_ast, persist: true)
Module.put_attribute(__MODULE__, :bacobj_ast, unquote(Macro.escape(ast)))
end
end
)
alias BACnet.Protocol.Constants
require Constants
# Implement Access Behaviour function fetch/2 for convenience
@doc false
defdelegate fetch(object, key), to: Map
# unquote(pv_mapper_ast)
defguardp is_remote(object) when object._metadata.remote_object != nil
defmacrop get_full_property_type_map() do
full = unquote(Macro.escape(properties_type_map))
quote do
unquote(Macro.escape(full))
end
end
#### Public API START ####
@doc """
Auto generated function to get the annotations for the given property name.
"""
@spec get_annotation(property_name()) :: [term()]
def get_annotation(name) when is_atom(name) do
Keyword.get(unquote(annotations), name, [])
end
@doc """
Auto generated function to get the list of annotations for each property.
"""
@spec get_annotations() :: [{name :: property_name(), values :: [term()]}]
def get_annotations() do
unquote(annotations)
end
if unquote(supports_intrinsic) do
@doc """
Checks if the given object has Intrinsic Reporting enabled.
"""
@spec intrinsic_reporting?(t()) :: boolean()
def intrinsic_reporting?(%__MODULE__{} = object) do
object._metadata.intrinsic_reporting
end
end
@doc """
Get the BACnet object identifier.
"""
@spec get_object_identifier(t()) :: BACnet.Protocol.ObjectIdentifier.t()
def get_object_identifier(%__MODULE__{} = object) do
%BACnet.Protocol.ObjectIdentifier{
type: unquote(object_type),
instance: object.object_instance
}
end
@doc """
Get the list of properties the object has.
"""
@spec get_properties(t()) :: [Constants.property_identifier()]
def get_properties(%__MODULE__{} = object) do
object._metadata.properties_list
end
@doc """
Checks if the given object has the given property.
See `BACnet.Protocol.ObjectsUtility.has_property?/2` for implementation details.
"""
@spec has_property?(t(), Constants.property_identifier()) :: boolean()
def has_property?(%__MODULE__{} = object, property) when is_atom(property) do
case check_property_exists(object, property) do
:ok -> true
_term -> false
end
end
@doc """
Checks if the given property is writable.
Check `BACnet.Protocol.ObjectsUtility.property_writable?/2` for a basic run-down.
"""
@spec property_writable?(t(), Constants.property_identifier()) :: boolean()
def property_writable?(%__MODULE__{} = object, property) when is_atom(property) do
BACnet.Protocol.ObjectsUtility.property_writable?(object, property)
end
@doc """
Creates a new object struct with the defined properties. Optional properties are not
created when not given, only required, given and dependency properties are created.
Properties with a value of `nil` are ignored.
Only properties that are required for specific services (i.e. Intrinsic Reporting)
are automatically created.#{if unquote(default_cov_increment) != nil do
"""
By default, a default `cov_increment` of `#{unquote(default_cov_increment)}` is used.
It is strongly advised to change this to something reasonable for the application.
"""
else
""
end}
"""
@spec create(
non_neg_integer(),
String.t(),
%{optional(property_name() | atom() | non_neg_integer()) => term()},
[
object_opts() | internal_metadata()
]
) :: {:ok, t()} | property_update_error()
def create(instance_number, object_name, properties \\ %{}, opts \\ [])
when is_integer(instance_number) and
is_binary(object_name) and
is_map(properties) and
instance_number >= 0 and
instance_number < Constants.macro_by_name(:asn1, :max_instance_and_property_id) do
with :ok <- check_printable_object_name(object_name),
{:ok, {props, unknown_props, needs_recheck}} <-
Enum.reduce_while(
properties,
{:ok, {%{}, %{}, false}},
&process_properties_from_create(&1, &2, opts)
),
metadata = create_metadata_from_opts(opts),
props when is_map(props) <- add_defaults(props, metadata),
props = Map.put(props, :object_name, object_name),
props = Map.put(props, :object_instance, instance_number),
new_metadata = %{metadata | properties_list: Map.keys(props)},
props = Map.put(props, :_metadata, new_metadata),
props = Map.put(props, :_unknown_properties, unknown_props),
{:ok, obj} <- check_implicit_relationships(struct(__MODULE__, props), :add),
# For objects with a priority_array, re-calculate the present value
obj =
(if match?(%{priority_array: %PriorityArray{}}, obj) do
pv =
case PriorityArray.get_value(obj.priority_array) do
nil -> obj.relinquish_default
{_prio, value} -> value
end
%{obj | present_value: pv}
else
obj
end),
# For now we'll always do the recheck, because property validators may be false negative
# due to how only partially the properties are present at check
:ok <-
(if needs_recheck or true do
# A recheck my be needed if a property depends on another but
# the other property has not been added yet to the accumulator map
# due to how map key ordering works, so we will run the property
# value check afterwards again, if the property-specific validation
# failed in the accumulator run (Enum.reduce_while/3)
Enum.reduce_while(props, :ok, fn
{:_metadata, _value}, _acc ->
{:cont, :ok}
{:_unknown_properties, _value}, _acc ->
{:cont, :ok}
# Do not check present_value if relinquish_default present (and set)
{:present_value, _value}, _acc
when :erlang.is_map_key(:relinquish_default, props) and
:erlang.map_get(:relinquish_default, props) != nil ->
{:cont, :ok}
{property, value}, _acc ->
# Protected properties do not need to be checked anymore,
# because the property may have been updated from the module code
# which would cause this error too -
# however if the user would've tried to set a protected property,
# this would've been caught error, so it's safe to not check
case check_property_value(props, property, value, false) do
:ok -> {:cont, :ok}
term -> {:halt, term}
end
end)
else
:ok
end),
{:ok, obj} <- verify_properties(obj, new_metadata) do
inhibit_object_check(obj)
else
{:ok, _val} = val ->
raise "Invalid return value from add_defaults/2, we got an ok-tuple"
term ->
term
end
end
@doc """
Adds an optional property to an object.
Remote objects can not be mutated using this operation.
Please note that properties of services can **not** be dynamically added and instead
the object must be newly created using `create/4`.
"""
@spec add_property(t(), Constants.property_identifier(), term()) ::
{:ok, t()} | property_update_error()
def add_property(object, property, value)
def add_property(%__MODULE__{} = object, _property, _value) when is_remote(object) do
{:error, :operation_not_available_for_remote_objects}
end
def add_property(%__MODULE__{} = object, property, value) when is_atom(property) do
with :ok <-
(if property in unquote(cleaned_fields) do
:ok
else
{:error, {:unknown_property, property}}
end),
:ok <-
(case check_property_exists(object, property) do
:ok -> {:error, {:property_already_exists, property}}
_term -> :ok
end),
:ok <-
(if property in unquote(intrinsic_properties) do
{:error, {:intrinsic_property_not_allowed, property}}
else
:ok
end),
:ok <- check_property_value(object, property, value, true) do
new_object = %{
object
| _metadata: %{
object._metadata
| properties_list: [property | object._metadata.properties_list]
}
}
new_object_result =
new_object
|> Map.put(property, value)
|> check_implicit_relationships(:add)
# If has priority_array and out_of_service is false,
# update the present_value with the correct value
# (priority_array may have been added (implicitely) and we need to sync the present_value)
unquote(
if :priority_array in struct_deffields do
quote do
new_object_result =
case new_object_result do
{:ok, new_object} ->
new_object2 =
if Map.get(new_object, :out_of_service) do
new_object
else
case new_object do
# The guard is needed so Dialyzer doesn't complain about unreachable patterns
%{priority_array: %PriorityArray{} = pa, relinquish_default: default}
when not is_nil(default) ->
present_value =
case PriorityArray.get_value(pa) do
{_prio, value} -> value
nil -> default
end
%{new_object | present_value: present_value}
_term ->
new_object
end
end
{:ok, new_object2}
other ->
other
end
end
end
)
case new_object_result do
{:ok, obj} -> inhibit_object_check(obj)
other -> other
end
end
end
@doc """
Get a property's value from an object.
"""
@spec get_property(t(), Constants.property_identifier() | non_neg_integer()) ::
{:ok, term()} | property_update_error()
def get_property(%__MODULE__{} = object, property) when is_atom(property) do
with :ok <- check_property_exists(object, property),
{:ok, value} <- Map.fetch(object, property) do
{:ok, value}
else
:error ->
{:error, {:unknown_property_identifier, property}}
# Allow to read unknown properties with atoms as key
{:error, {:unknown_property_identifier, _prop}} = err ->
case Map.fetch(object._unknown_properties, property) do
{:ok, _val} = val -> val
:error -> err
end
term ->
term
end
end
def get_property(%__MODULE__{} = object, property)
when is_integer(property) and property >= 0 do
case Map.fetch(object._unknown_properties, property) do
{:ok, _val} = val -> val
:error -> {:error, {:unknown_property_identifier, property}}
end
end
@doc """
Removes an optional property from an object. This function is idempotent.
Remote objects can not be mutated using this operation.
Please note that properties of services can **not** be dynamically removed and instead
the object must be newly created using `create/4`. Required properties can not be removed.
"""
@spec remove_property(t(), Constants.property_identifier() | non_neg_integer()) ::
{:ok, t()} | property_update_error()
def remove_property(object, property)
def remove_property(%__MODULE__{} = object, property) when is_remote(object) do
{:error, :operation_not_available_for_remote_objects}
end
def remove_property(%__MODULE__{} = object, property) when is_atom(property) do
with :ok <- check_property_exists(object, property),
:ok <- check_property_allowed_remove(object, property) do
new_object = %{
object
| _metadata: %{
object._metadata
| properties_list: List.delete(object._metadata.properties_list, property)
}
}
new_object
|> Map.put(property, nil)
|> check_implicit_relationships(:remove)
|> case do
{:ok, obj} -> inhibit_object_check(obj)
other -> other
end
else
{:error, {:unknown_property, _property}} -> {:ok, object}
term -> term
end
end
def remove_property(%__MODULE__{} = object, property)
when is_integer(property) and property >= 0 do
{:ok, %{object | _unknown_properties: Map.delete(object._unknown_properties, property)}}
end
@doc """
Updates a property of an object.#{if :priority_array in unquote(struct_deffields) do
" To update the priority array, use `set_priority/3` instead."
end}
"""
@spec update_property(t(), Constants.property_identifier(), term()) ::
{:ok, t()} | property_update_error()
def update_property(%__MODULE__{} = object, property, value) when is_atom(property) do
with :ok <- check_property_exists(object, property),
:ok <- prevent_commandable_objects_write_pv(object, property),
:ok <-
(if object._metadata.remote_object do
# Do not check if remote object
:ok
else
check_property_intrinsic(
unquote(call_intrinsic_on_object_check),
property,
value,
false
)
end),
:ok <- check_property_value(object, property, value, true) do
new_object = Map.put(object, property, value)
# If has priority_array and out_of_service is false,
# update the present_value with the correct value
# (priority_array may have been added (implicitely) and we need to sync the present_value)
unquote(
if :priority_array in struct_deffields do
quote do
new_object =
if Map.get(new_object, :out_of_service) do
new_object
else
case new_object do
# The guard is needed so Dialyzer doesn't complain about unreachable patterns
%{priority_array: %PriorityArray{} = pa, relinquish_default: default}
when not is_nil(default) ->
present_value =
case PriorityArray.get_value(pa) do
{_prio, value} -> value
nil -> default
end
%{new_object | present_value: present_value}
_term ->
new_object
end
end
end
end
)
# Update the feedback_value property with the present value
unquote(
if :feedback_value in struct_fields do
quote do
new_object =
if object._metadata.other[:auto_write_feedback] do
%{new_object | feedback_value: new_object.present_value}
else
new_object
end
end
end
)
inhibit_object_check(new_object)
end
end
if unquote(pv_ex_type) != nil and :priority_array in unquote(struct_deffields) and
:priority_array not in unquote(required_properties) do
@doc """
Checks whether the given object has a priority array.
"""
@spec has_priority_array?(t()) :: boolean()
def has_priority_array?(%__MODULE__{priority_array: %PriorityArray{} = _pa} = _object),
do: true
def has_priority_array?(%__MODULE__{} = _object), do: false
end
# Ignore properties with nil values
@spec process_properties_from_create(
{property_name(), term()},
{:ok, {map(), map(), boolean()}},
Keyword.t()
) :: {:ok, {map(), map(), boolean()}} | {:halt, {:error, term()}}
defp process_properties_from_create({_prop, nil}, acc, _opts), do: {:cont, acc}
defp process_properties_from_create({prop, val}, {:ok, {acc, unknown_acc, flag}}, opts) do
allow_unknown = Keyword.get(opts, :allow_unknown_properties, false)
skip_unknown = Keyword.get(opts, :ignore_unknown_properties, false)
is_remote_object = !!opts[:remote_object]
with :ok <-
check_property_exists(
%{_metadata: %{properties_list: unquote(cleaned_fields)}},
prop
),
# If property fails property validation, we shall pass it for now,
# but we need to recheck after all properties have been added
# This is caused by random map keys ordering introduced with OTP 26
# but may aswell have been needed to not depend on key ordering...
{:ok, needs_recheck} <-
(case check_property_value(acc, prop, val, true) do
:ok -> {:ok, false}
{:error, {:value_failed_property_validation, _property}} -> {:ok, true}
term -> term
end),
:ok <-
(if is_remote_object do
# Do not check if remote object
:ok
else
check_property_intrinsic(
Keyword.get(opts, :intrinsic_reporting, false),
prop,
val,
false
)
end) do
{:cont, {:ok, {Map.put(acc, prop, val), unknown_acc, flag or needs_recheck}}}
else
{:error, {:unknown_property, prop}}
when allow_unknown and
((is_remote_object and is_atom(prop)) or (is_integer(prop) and prop >= 0)) ->
{:cont, {:ok, {acc, Map.put(unknown_acc, prop, val), flag}}}
{:error, {:unknown_property, _prop}} when skip_unknown ->
{:cont, {:ok, {acc, unknown_acc, flag}}}
{:error, _err} = err ->
{:halt, err}
end
end
defp add_defaults(properties, metadata) do
props = Map.merge(unquote(Macro.escape(Map.new(default_properties))), properties)
# If min and max present, make sure to set relinquish_default if not set
unquote(
if Enum.all?(
[:relinquish_default, :min_present_value, :max_present_value],
&(&1 in struct_deffields)
) do
quote do
props =
if Map.get(props, :priority_array) && Map.get(props, :min_present_value) &&
Map.get(props, :max_present_value) do
Map.put_new_lazy(props, :relinquish_default, fn ->
Map.get(props, :min_present_value)
end)
else
props
end
end
end
)
# Insert cov_increment property if not remote object
props =
if unquote(default_cov_increment) != nil and metadata.remote_object == nil do
Map.put_new(props, :cov_increment, unquote(default_cov_increment))
else
props
end
# Insert intrinsic properties if intrinsic reporting enabled
props =
if unquote(supports_intrinsic) and metadata.intrinsic_reporting and
metadata.remote_object == nil do
Map.merge(unquote(Macro.escape(Map.new(default_intrinsic_properties))), props)
else
props
end
# Insert properties which have an init_fun function for local objects
# Properties with init_fun get automatically added (as if required properties)
props =
if metadata.remote_object do
props
else
Enum.reduce(unquote(Macro.escape(init_fun_map)), props, fn
{name, init_fun}, acc -> Map.put_new_lazy(acc, name, init_fun)
end)
end
props
end
# Check implicit relationships, modify the object (add or remove), return new one
# This is a default implementation
@spec check_implicit_relationships(t(), :add | :remove) ::
{:ok, t()} | property_update_error()
defp check_implicit_relationships(%__MODULE__{} = object, operation)
when operation in [:add, :remove] do
relationships = unquote(Macro.escape(implicit_relationships))
Enum.reduce_while(relationships, {:ok, object}, fn {prop, relat}, {:ok, acc} ->
has_key1 = Enum.member?(object._metadata.properties_list, prop)
has_key2 = Enum.member?(object._metadata.properties_list, relat)
if has_key1 != has_key2 do
{del_key, new_key} =
cond do
has_key1 -> {prop, relat}
has_key2 -> {relat, prop}
end
if operation == :add do
# Only add the key if the key exists in the struct
if new_key in unquote(struct_deffields) do
case Map.fetch(unquote(Macro.escape(default_properties_all)), new_key) do
{:ok, new_value} ->
acc
|> Map.put(
new_key,
new_value
)
|> then(
&update_in(&1, [Access.key(:_metadata), :properties_list], fn list ->
[new_key | list]
end)
)
|> then(&{:cont, {:ok, &1}})
:error ->
{:halt, {:error, {:missing_optional_property, new_key}}}
end
else
{:cont, {:ok, acc}}
end
else
acc
|> Map.delete(del_key)
|> then(
&update_in(&1, [Access.key(:_metadata), :properties_list], fn list ->
List.delete(list, del_key)
end)
)
|> then(&{:cont, {:ok, &1}})
end
else
{:cont, {:ok, acc}}
end
end)
end
@spec check_property_allowed_remove(t(), Constants.property_identifier()) ::
:ok | property_update_error()
defp check_property_allowed_remove(object, property) do
if property in unquote(required_properties) or
property in unquote(intrinsic_properties) or
property in unquote(cov_properties) or
check_if_property_optionally_required(object, object._metadata, property) do
{:error, {:property_not_allowed, property}}
else
:ok
end
end
@spec get_default_properties() :: map()
defp get_default_properties(), do: unquote(Macro.escape(default_properties_all))
@spec verify_properties(map(), internal_metadata()) ::
{:ok, map()} | property_update_error()
defp verify_properties(properties, metadata) do
# First, verify if all required properties as per object spec are present
# and verify their revision status (require newer properties if revision reached)
with :ok <-
Enum.reduce_while(unquote(required_properties), :ok, fn req_prop, _acc ->
rev_status = Map.get(unquote(Macro.escape(properties_revision_map)), req_prop)
cond do
Map.has_key?(properties, req_prop) -> {:cont, :ok}
is_integer(rev_status) and metadata.revision < rev_status -> {:cont, :ok}
true -> {:halt, {:error, {:missing_required_property, req_prop}}}
end
end),
# Second, look through annotations and support `required_when: ...` annotations
# If the function returns true, the property is (optionally) required,
# thus we need to check if it is present or if it has a default value
# If neither is the case and the property is required, error out
{:ok, properties} <- verify_properties_with_required_when(properties, metadata) do
# Third, look through annotations and support `only_when: ...` annotations
# If the function returns true, the property is (optionally) required,
# thus we need to check if it is present or if it has a default value,
# if neither is the case and the property is required, error out
# If the function returns false, the property MUST NOT be present,
# thus we need to check if it is present and error out if so.
verify_properties_with_only_when(properties, metadata)
end
end
@spec check_if_property_optionally_required(
map(),
map(),
Constants.property_identifier()
) :: boolean()
defp check_if_property_optionally_required(properties, metadata, property) do
annotation = get_annotation(property)
if is_list(annotation) do
case Keyword.fetch(annotation, :required_when) do
{:ok, value} ->
verify_properties_required_when(properties, metadata, value)
:error ->
false
end
else
false
end
end
@spec verify_properties_required_when(map(), map(), term()) :: boolean()
defp verify_properties_required_when(properties, metadata, annotation)
defp verify_properties_required_when(properties, metadata, {:property, property}) do
check_property_exists(%{_metadata: metadata}, property) == :ok
end
defp verify_properties_required_when(properties, metadata, {:property, property, value}) do
check_property_exists(%{_metadata: metadata}, property) == :ok and
Map.get(properties, property) === value
end
defp verify_properties_required_when(properties, metadata, {:property, property, op, value}) do
check_property_exists(%{_metadata: metadata}, property) == :ok and
apply(Kernel, op, [Map.get(properties, property), value]) == true
end
defp verify_properties_required_when(properties, metadata, {:opts, option}) do
with :error <- Map.fetch(metadata, option),
:error <- Map.fetch(metadata.other, option) do
false
else
{:ok, opt_val} -> opt_val == true
end
end
defp verify_properties_required_when(properties, metadata, {:opts, option, value}) do
with :error <- Map.fetch(metadata, option),
:error <- Map.fetch(metadata.other, option) do
false
else
{:ok, opt_val} -> opt_val === value
end
end
defp verify_properties_required_when(properties, metadata, {:opts, option, op, value}) do
with :error <- Map.fetch(metadata, option),
:error <- Map.fetch(metadata.other, option) do
false
else
{:ok, opt_val} -> apply(Kernel, op, [opt_val, value]) == true
end
end
defp verify_properties_required_when(properties, _metadata, fun) when is_function(fun, 1) do
fun.(properties) == true
end
defp verify_properties_required_when(properties, metadata, fun) when is_function(fun, 2) do
fun.(properties, metadata) == true
end
defp verify_properties_required_when(properties, _metadata, _other), do: false
@spec verify_properties_with_required_when(map(), internal_metadata()) ::
{:ok, map()} | property_update_error()
defp verify_properties_with_required_when(properties, metadata) do
# We directly compute the annotations to only the ones with :required_when
# at compile time to reduce runtime performance impact
Enum.reduce_while(
unquote(
Enum.reduce(annotations, [], fn {name, key_annotations}, acc ->
case Keyword.fetch(key_annotations, :required_when) do
{:ok, value} -> [{name, value} | acc]
:error -> acc
end
end)
),
{:ok, properties},
fn {name, annotation}, {:ok, properties} ->
with true <- verify_properties_required_when(properties, metadata, annotation),
{:error, _term} <- check_property_exists(%{_metadata: metadata}, name),
{:ok, default_val} <-
Map.fetch(unquote(Macro.escape(default_properties_all)), name) do
{:cont, {:ok, Map.put(properties, name, default_val)}}
else
term when term in [true, false, :ok] -> {:cont, {:ok, properties}}
_else -> {:halt, {:error, {:missing_required_property, name}}}
end
end
)
end
@spec verify_properties_with_only_when(map(), internal_metadata()) ::
{:ok, map()} | property_update_error()
defp verify_properties_with_only_when(properties, metadata) do
# We directly compute the annotations to only the ones with :only_when
# at compile time to reduce runtime performance impact
Enum.reduce_while(
unquote(
Enum.reduce(annotations, [], fn {name, key_annotations}, acc ->
case Keyword.fetch(key_annotations, :only_when) do
{:ok, value} -> [{name, value} | acc]
:error -> acc
end
end)
),
{:ok, properties},
fn {name, annotation}, {:ok, properties} ->
required_state = verify_properties_required_when(properties, metadata, annotation)
has_key = check_property_exists(%{_metadata: metadata}, name) == :ok
cond do
required_state and not has_key ->
with {:ok, default_val} <-
Map.fetch(unquote(Macro.escape(default_properties_all)), name) do
{:cont, {:ok, Map.put(properties, name, default_val)}}
else
_else -> {:halt, {:error, {:missing_required_property, name}}}
end
not required_state and has_key ->
{:halt, {:error, {:property_not_allowed, name}}}
true ->
{:cont, {:ok, properties}}
end
end
)
end
# Stuff for objects with priority_array property (and present_value property)
if unquote(pv_ex_type) != nil and :priority_array in unquote(struct_deffields) do
alias BACnet.Protocol.PriorityArray
@doc """
Get the active priority value from the priority array, or nil.
"""
@spec get_priority_value(t()) ::
{priority :: 1..16, value :: unquote(pv_typespec)} | nil
def get_priority_value(%__MODULE__{priority_array: nil} = object) do
nil
end
def get_priority_value(
%__MODULE__{priority_array: %PriorityArray{} = prio_array} = object
) do
PriorityArray.get_value(prio_array)
end
@doc """
Sets the given priority in the priority array of an object.
This function also updates the present value.
"""
@spec set_priority(t(), 1..16, unquote(pv_typespec) | nil) ::
{:ok, t()} | property_update_error()
def set_priority(%__MODULE__{priority_array: nil} = _object, _priority, _value) do
{:error, {:unknown_property, :priority_array}}
end
def set_priority(
%__MODULE__{priority_array: %PriorityArray{} = prio_array} = object,
priority,
value
)
when priority in 1..16 do
pv_check =
if value == nil do
:ok
else
check_property_value(object, :present_value, value, true)
end
case pv_check do
:ok ->
new_prio = Map.put(prio_array, PriorityArray.int_to_atom(priority), value)
new_object = %{object | priority_array: new_prio}
# If object is out of service, do not re-write present value
new_object =
if object.out_of_service do
new_object
else
present_value =
case PriorityArray.get_value(new_prio) do
{_prio, value} -> value
nil -> new_object.relinquish_default
end
%{new_object | present_value: present_value}
end
# Update the feedback_value property with the present value
unquote(
if :feedback_value in struct_fields do
quote do
new_object =
if object._metadata.other[:auto_write_feedback] do
%{new_object | feedback_value: new_object.present_value}
else
new_object
end
end
end
)
{:ok, new_object}
{:error, {key, :present_value}} ->
{:error, {key, :priority_array}}
term ->
term
end
end
defoverridable set_priority: 3
end
@spec inhibit_object_check(t()) :: {:ok, t()} | {:error, term()}
defp inhibit_object_check(obj), do: {:ok, obj}
defoverridable create: 2,
create: 3,
create: 4,
add_property: 3,
remove_property: 2,
update_property: 3,
property_writable?: 2,
add_defaults: 2,
check_implicit_relationships: 2,
inhibit_object_check: 1
#### Public API END ####
@typedoc false
@type internal_metadata :: %{
properties_list: [Constants.property_identifier()],
revision: non_neg_integer(),
intrinsic_reporting: boolean(),
remote_object: non_neg_integer() | true | nil,
physical_input: boolean() | nil,
other: map()
}
#### Public types START ####
@typedoc """
Common object options for creation - all are optional.
- `allow_unknown_properties` - Properties that are unknown to the object implementation are usually rejected.
With this option, unknown properties (numeric identifiers usually means we dont know them) are accepted
and put into a separate map. This does mean we can not validate or write them.
Types of the values can be anything at this point. While you can read unknown properties with atom
or integer as property identifier, you can only remove numeric unknown property identifiers from an object.
Property identifiers of type `atom` are only accepted, if it is a remote object (object implementation is only
enforced if it is a local object). Numeric property identifiers are accepted regardless of remote object or not.
For remote objects, this means you have to write "raw values" (usually `Encoding` structs).
- `ignore_unknown_properties` - Properties that are unknown to the object implementation are usually rejected.
With this option, unknown properties get ignored, as if they were not specified.
- `revision` - The BACnet protocol revision to check required properties against.
Optional properties are regardless of revision available.
See `t:BACnet.Protocol.Constants.protocol_revision/0` for the available revisions.
"""
@type common_object_opts ::
{:allow_unknown_properties, boolean()}
| {:ignore_unknown_properties, boolean()}
| {:revision, Constants.protocol_revision()}
@typedoc """
Available property names for this object.
"""
@type property_name :: unquote(fields)
@typedoc """
The structure for property errors.
"""
@type property_update_error ::
{:error, {error :: atom(), property :: Constants.property_identifier()}}
#### Public types END ####
#### Additional generated functions START ####
@doc """
Auto generated function to get the names of all properties this object supports.
"""
@spec get_all_properties() :: [Constants.property_identifier()]
def get_all_properties(), do: unquote(cleaned_fields)
@doc """
Auto generated function to get the names of properties used for COV reporting.
"""
@spec get_cov_properties() :: [Constants.property_identifier()]
def get_cov_properties(), do: unquote(cov_properties)
@doc """
Auto generated function to get the names of intrinsic properties.
"""
@spec get_intrinsic_properties() :: [Constants.property_identifier()]
def get_intrinsic_properties(), do: unquote(intrinsic_properties)
@doc """
Auto generated function to get the names of optional properties.
"""
@spec get_optional_properties() :: [Constants.property_identifier()]
def get_optional_properties(), do: unquote(cleaned_fields -- required_properties)
@doc """
Auto generated function to get the names of protected properties.
Protected is an annotation and the object modules prevent writing to
this property directly in code. The protected properties are either
written on creation or updated automatically depending on other properties
being written to. Some properties are only written once at creation and
never updated.
"""
@spec get_protected_properties() :: [Constants.property_identifier()]
def get_protected_properties(), do: unquote(protected_properties)
@doc """
Auto generated function to get the names of readonly properties.
Readonly is only an annotation that the property should be write protected
on the BACnet side, there is no actual write protection in the object.
This is a hint to the device server. If you need actual write protection, see `protected`.
"""
@spec get_readonly_properties() :: [Constants.property_identifier()]
def get_readonly_properties(), do: unquote(readonly_properties)
@doc """
Auto generated function to get the names of required properties.
"""
@spec get_required_properties() :: [Constants.property_identifier()]
def get_required_properties(), do: unquote(required_properties)
@doc """
Auto generated function to check whether the object type supports intrinsic reporting.
"""
@spec supports_intrinsic() :: boolean()
def supports_intrinsic(), do: unquote(supports_intrinsic)
@doc """
Auto generated function to get a map of property name to type.
"""
@spec get_properties_type_map() :: map()
def get_properties_type_map() do
get_full_property_type_map()
end
#### Additional generated functions END ####
@spec check_property_exists(map(), Constants.property_identifier()) ::
:ok | property_update_error()
defp check_property_exists(%{_metadata: %{properties_list: properties}} = object, property) do
if property in properties do
:ok
else
{:error, {:unknown_property, property}}
end
end
@spec prevent_commandable_objects_write_pv(map(), Constants.property_identifier()) ::
:ok | property_update_error()
defp prevent_commandable_objects_write_pv(
%{priority_array: %PriorityArray{} = _pa, out_of_service: false} = object,
:present_value
) do
{:error, {:protected_property, :present_value}}
end
defp prevent_commandable_objects_write_pv(_object, _property), do: :ok
@spec check_property_value(t(), Constants.property_identifier(), term(), boolean()) ::
:ok | property_update_error()
defp check_property_value(object, property, value, check_protected)
# The property priority_array needs some special handling
unquote(
if :priority_array in struct_deffields do
quote do
defp check_property_value(
object,
:priority_array,
%PriorityArray{} = value,
check_protected
) do
value
|> Map.from_struct()
|> Enum.reduce_while(:ok, fn {_key, val}, acc ->
if val == nil do
{:cont, acc}
else
case check_property_value(object, :present_value, val, check_protected) do
:ok -> {:cont, acc}
{:error, {term, _prop}} -> {:halt, {:error, {term, :priority_array}}}
end
end
end)
end
end
end
)
defp check_property_value(object, property, value, check_protected) do
case Map.fetch(get_full_property_type_map(), property) do
:error ->
{:error, {:unknown_type_for_property, property}}
{:ok, type} ->
cond do
# Prevent writes to protected properties (they're OK to read if existing)
check_protected and property in unquote(protected_properties) ->
{:error, {:protected_property, property}}
Internal.check_type(type, value) ->
case Keyword.fetch(unquote(properties_validators), property) do
:error ->
:ok
{:ok, {nil, nil}} ->
:ok
{:ok, {tfun, vfun}} ->
err = {:error, {:value_failed_property_validation, property}}
cond do
not apply_validator_fun(tfun, value, object, type) -> err
not apply_validator_fun(vfun, value, object, type) -> err
true -> :ok
end
end
true ->
{:error, {:invalid_property_type, property}}
end
end
end
defp apply_validator_fun(val_fun, value, object, type)
defp apply_validator_fun(val_fun, value, object, _type) do
cond do
is_function(val_fun, 0) -> val_fun.()
is_function(val_fun, 1) -> val_fun.(value)
is_function(val_fun, 2) -> val_fun.(value, object)
true -> true
end
end
@spec check_property_intrinsic(
boolean(),
Constants.property_identifier(),
term(),
boolean()
) :: :ok | property_update_error()
defp check_property_intrinsic(object_is_intrinsic, property, value, allow_prop_nil)
defp check_property_intrinsic(false, property, _value, _allow_prop_nil) do
if property in unquote(intrinsic_properties) do
{:error, {:intrinsic_property_not_available, property}}
else
:ok
end
end
defp check_property_intrinsic(true, property, nil, false) do
{:error, {:intrinsic_property_is_nil, property}}
end
defp check_property_intrinsic(true, property, value, _allow_prop_nil) do
:ok
end
defp check_printable_object_name(name) do
if byte_size(name) > 0 and String.valid?(name) and String.printable?(name) do
:ok
else
{:error, {:invalid_non_printable_object_name, :object_name}}
end
end
@default_value_phys_input (if Enum.member?(unquote(struct_deffields), :physical_input) do
false
else
nil
end)
defp create_metadata_from_opts(opts) do
metadata = %{
properties_list: [],
revision:
Constants.by_name!(
:protocol_revision,
Keyword.get(
opts,
:revision,
Constants.macro_by_name(:protocol_revision, :default)
)
),
intrinsic_reporting: !!Keyword.get(opts, :intrinsic_reporting, false),
remote_object: Keyword.get(opts, :remote_object, nil),
physical_input:
case Keyword.get(opts, :physical_input, @default_value_phys_input) do
nil -> nil
term -> !!term
end,
other:
Map.new(
Keyword.drop(opts, [
:properties_list,
:revision,
:ignore_unknown_properties,
:intrinsic_reporting,
:remote_object,
:physical_input
])
)
}
if not unquote(supports_intrinsic) and metadata.intrinsic_reporting do
raise ArgumentError, "Object does not support intrinsic reporting"
end
metadata
end
defimpl Inspect do
import Inspect.Algebra
@name String.replace("#{@for}", "Elixir.", "")
# This code has been taken from the Inspect.Map module and slightly adjusted
def inspect(object, opts) do
# If properties list is empty, this may be inside a pattern match
# Since this is not the case usual for proper creation, expose all keys
list =
if object._metadata.properties_list == [] do
# Enum.reject(object, &is_nil/1)
object
else
object
|> Map.take([:_unknown_properties | object._metadata.properties_list])
|> Map.to_list()
|> Enum.sort_by(fn {key, _val} ->
str = Atom.to_string(key)
if String.starts_with?(str, "_") do
binary_part(str, 1, byte_size(str) - 1)
else
str
end
end)
end
fun =
if Inspect.List.keyword?(list) do
&Inspect.List.keyword/2
else
sep = color(" => ", :map, opts)
&to_assoc(&1, &2, sep)
end
map_container_doc(list, @name, opts, fun)
end
defp to_assoc({key, value}, opts, sep) do
concat(concat(to_doc(key, opts), sep), to_doc(value, opts))
end
defp map_container_doc(list, name, opts, fun) do
open = color("#" <> name <> "<", :map, opts)
sep = color(",", :map, opts)
close = color(">", :map, opts)
container_doc(open, list, close, opts, fun, separator: sep, break: :strict)
end
end
end
end
defp get_services_data({:services, _meta, [services]}, _env) do
%{
intrinsic: !!Keyword.get(services, :intrinsic, false)
}
end
defp get_field_data({:field, _meta, [name, typespec]}, env) do
get_field_data({:field, [], [name, typespec, []]}, env)
end
defp get_field_data({:field, meta, [name, typespec, opts]}, env) do
# Verify the property name is valid (skip "internal" properties)
if name not in [:_metadata, :object_instance] and
not Constants.has_by_name(:property_identifier, name) do
raise ArgumentError, "Unknown property name \"#{inspect(name)}\""
end
bac_type_lazy =
case Keyword.fetch(opts, :bac_type) do
{:ok, ast} ->
case ast do
# Expand {:with_validator, type, validator_fun}
{:{}, _line, args} ->
List.to_tuple(args)
# Allow types that are valid for Internal.check_type/2
{key, _term}
when key in [
:array,
:constants,
:in_list,
:list,
:literal,
:struct,
:tuple,
:type_list
] ->
ast
# Allow types that are valid for Internal.check_type/2
{key, _term, _term2} when key in [:array, :in_range, :with_validator] ->
ast
# Otherwise only types (such as :double)
term when is_atom(term) ->
term
_term ->
raise ArgumentError, "Invalid bac_type for field #{name}, got: #{inspect(ast)}"
end
:error ->
Internal.resolve_type(typespec, %{env | line: meta[:line] || env.line})
end
{bac_type, type_validator} =
case bac_type_lazy do
# I'm not sure how we want to test {:list, {:with_validator, ..., ..}},
# it probably requires some work to make it work (and properly expand from AST)
#
# {:list, {:with_validator, bac_type, type_validator}} ->
# {{:list, bac_type}, type_validator}
{:with_validator, bac_type, type_validator} ->
{bac_type, type_validator}
bac_type ->
{bac_type, nil}
end
required = Keyword.get(opts, :required, false)
readonly = Keyword.get(opts, :readonly, false)
protected = Keyword.get(opts, :protected, false)
cov = Keyword.get(opts, :cov, false)
intrinsic = Keyword.get(opts, :intrinsic, false)
default = Keyword.get(opts, :default, nil)
implicit_relationship = Keyword.get(opts, :implicit_relationship, nil)
annotations = List.flatten(Keyword.get_values(opts, :annotation))
validator_fun =
case Keyword.get(opts, :validator_fun, nil) do
nil ->
nil
ast ->
case Macro.expand(ast, env) do
# Capture operator
{:&, _meta, _args} ->
ast
# Anonymous function using `fn` macro
{:fn, _meta, _args} ->
ast
_term ->
raise ArgumentError, "Invalid validator_fun for field #{name}, " <> "
not a function definition (& capture or fn), " <> "got: #{inspect(ast)}"
end
end
init_fun =
case Keyword.get(opts, :init_fun, nil) do
nil ->
nil
ast ->
case Macro.expand(ast, env) do
{:&, _meta, _args} = ast ->
fun = elem(Code.eval_quoted(ast, [], env), 0)
if is_function(fun, 0) do
fun
else
raise ArgumentError, "Invalid init_fun for field #{name} given, " <> "
function captures with arity > 0 are not supported"
end
ast ->
raise ArgumentError, "Invalid init_fun for field #{name} given, " <> "
expected a remote function capture with arity 0, " <> "got: #{inspect(ast)}"
end
end
# Expand the AST for default
default =
case Macro.expand(default, env) do
# Nothing
nil ->
nil
# Function call, execute it at compile time
{{:., _meta, [{:__aliases__, _meta2, _module}, fun]}, _any, args} = ast
when is_atom(fun) and is_list(args) ->
elem(Code.eval_quoted(ast, [], env), 0)
# Captured function, execute it at compile time
# Only functions with arity 0, outside of the module, are supported
{:&, _meta, _args} = ast ->
fun = elem(Code.eval_quoted(ast, [], env), 0)
if is_function(fun, 0) do
fun.()
else
raise ArgumentError, "Function captures with arity > 0 are not supported"
end
# Function, execute it at compile time
# Only function with arity 0
{:fn, _meta, _args} = ast ->
fun = elem(Code.eval_quoted(ast, [], env), 0)
if is_function(fun, 0) do
fun.()
else
raise ArgumentError, "Functions with arity > 0 are not supported"
end
# Map definition, execute it at compile time
{:%, _meta, _more} = ast ->
elem(Code.eval_quoted(ast, [], env), 0)
# String concatenation, execute it at compile time
{:<<>>, _meta, _more} = ast ->
elem(Code.eval_quoted(ast, [], env), 0)
# Variable
{var, meta, nil} when is_atom(var) and is_list(meta) ->
raise ArgumentError, "Variables can not be given to the default value"
term ->
term
end
if bac_type != nil and default != nil and not Internal.check_type(bac_type, default) do
raise ArgumentError,
"Invalid default value for field #{name}, " <>
"expected type \"#{inspect(bac_type)}\", " <> "got value: #{inspect(default)}"
end
if implicit_relationship == name do
raise ArgumentError,
"Invalid implicit relationship value for field #{name}, " <>
"expected a different value than the field name (#{name})"
end
%{
name: name,
typespec: typespec,
bac_type: bac_type,
required: required and not intrinsic,
readonly: readonly,
protected: protected,
cov: cov,
intrinsic: intrinsic,
default: default,
implicit_relationship: implicit_relationship,
type_validator: type_validator,
validator_fun: validator_fun,
init_fun: init_fun,
annotations: annotations
}
end
#### Internal default properties and implicit relationships helpers START ####
@spec get_default_implicit_relationships() :: Keyword.t()
defp get_default_implicit_relationships() do
[
priority_array: :relinquish_default
]
end
@spec get_default_intrinsic_properties() :: Keyword.t()
defp get_default_intrinsic_properties() do
[
acked_transitions: get_default_event_transbits(true),
event_algorithm_inhibit: false,
event_algorithm_inhibit_ref: get_default_object_ref(),
event_detection_enable: true,
event_enable: get_default_event_transbits(false),
event_message_texts: %BACnet.Protocol.EventMessageTexts{
to_offnormal: "",
to_fault: "",
to_normal: ""
},
event_message_texts_config: get_default_event_message_texts(),
event_timestamps: %BACnet.Protocol.EventTimestamps{
to_offnormal: @default_bacnet_timestamp,
to_fault: @default_bacnet_timestamp,
to_normal: @default_bacnet_timestamp
},
limit_enable: %BACnet.Protocol.LimitEnable{
low_limit_enable: false,
high_limit_enable: false
},
notify_type: Constants.macro_assert_name(:notify_type, :alarm),
notification_class: 0,
time_delay: 0,
time_delay_normal: 0
]
end
@spec get_default_optional_properties() :: Keyword.t()
defp get_default_optional_properties() do
[
reliability: Constants.macro_assert_name(:reliability, :no_fault_detected),
reliability_evaluation_inhibit: false,
priority_array: %PriorityArray{}
]
end
@spec get_default_required_properties() :: Keyword.t()
defp get_default_required_properties() do
[
event_state: Constants.macro_assert_name(:event_state, :normal),
out_of_service: false,
status_flags: %BACnet.Protocol.StatusFlags{
in_alarm: false,
fault: false,
overridden: false,
out_of_service: false
}
]
end
#### Internal default properties and implicit relationships helpers END ####
@spec generate_moduledoc(Constants.object_type(), [map()]) :: String.t()
defp generate_moduledoc(object_type, fields_data) do
properties =
fields_data
|> Enum.reject(fn %{name: name} -> String.starts_with?(Atom.to_string(name), "_") end)
|> Enum.sort_by(fn %{name: name} -> name end, :asc)
properties_table =
Enum.map_join(properties, "\n", fn field ->
"| #{field.name} | #{field.annotations[:revision] || ""} | #{bool_to_string(field.required)} " <>
"| #{bool_to_string(field.readonly)} | #{bool_to_string(field.protected)} " <>
"| #{bool_to_string(field.intrinsic)} |"
end)
props_info_table =
properties
|> Enum.filter(fn field ->
field.default || field.init_fun || field.implicit_relationship || field.validator_fun ||
field.type_validator || field.annotations != []
end)
|> Enum.map_join("\n", fn field ->
validators =
[]
|> then(&if field.validator_fun, do: ["Fun" | &1], else: &1)
|> then(&if field.type_validator, do: ["Type" | &1], else: &1)
|> Enum.reverse()
|> Enum.join("/")
annotations =
case field.annotations do
[] -> ""
_else -> "`#{String.trim(String.trim(inspect(field.annotations), "["), "]")}`"
end
"| #{field.name} | #{bool_to_string(field.default != nil)} | #{bool_to_string(field.init_fun != nil)} " <>
"| #{field.implicit_relationship} | #{validators} | #{annotations} |"
end)
props_defaults_table =
properties
|> Enum.filter(fn field ->
field.default || field.init_fun
end)
|> Enum.map_join("\n", fn field ->
default =
case field.default do
nil ->
""
%name{} ->
title =
field.default
|> inspect(pretty: false)
|> String.replace("\"", """)
"`%#{String.replace("#{name}", "Elixir.", "")}{...}`"
_else ->
"`#{inspect(field.default)}`"
end
init_fun =
case field.init_fun do
nil ->
""
_else ->
"`#{String.trim_leading(String.replace(inspect(field.init_fun),
"BACnet.Protocol.ObjectsUtility.Internal",
"Utility.Internal"),
"&")}`"
end
"| #{field.name} | #{default} | #{init_fun} |"
end)
"""
---------------------------------------------------------------------------
The following part has been automatically generated.
Click to expand
This module defines a BACnet object of the type `#{object_type}`. The following properties are defined:
| Property | Revision | Required | Readonly | Protected | Intrinsic |
|----------|----------|----------|----------|-----------|-----------|
#{properties_table}
The following properties have additional semantics:
| Property | Has Default | Has Init | Implicit Relationships | Validators | Annotations |
|----------|-------------|----------|------------------------|------------|-------------|
#{props_info_table}
The following table shows the default values and/or init functions:
| Property | Default Value | Init Function |
|----------|---------------|---------------|
#{props_defaults_table}
"""
end
defp bool_to_string(true), do: "X"
defp bool_to_string(false), do: ""
end