Packages

BACstack is a low-level Elixir implementation for the ASHRAE standard 135, BACnet - Building Automation and Controller network. In its current published state, v0.0.x, it can (only) act as a BACnet client for other BACnet devices.

Current section

Files

Jump to
bacstack lib bacnet protocol destination.ex
Raw

lib/bacnet/protocol/destination.ex

defmodule BACnet.Protocol.Destination do
  @moduledoc """
  A Destination describes where and when event notifications should be sent for a
  particular recipient. It is the core data structure used inside Notification Class
  objects to define the distribution list for alarms and events.

  Each destination combines a Recipient (which can be a device, a broadcast address,
  or an unicast address), a process identifier that the recipient
  will use to correlate the notification, a flag indicating whether confirmed or
  unconfirmed notifications should be used, the set of transitions (to-offnormal,
  to-fault, to-normal) that should trigger a notification, and a time window
  (valid days + from/to time) during which the subscription is active.

  This structure allows very fine-grained control over alarm routing. For example,
  a critical high-priority alarm can be sent 24/7 to an operator workstation with
  confirmed notifications, while the same alarm can be sent only during business
  hours via unconfirmed notifications to a logging server. The time and day
  restrictions are evaluated by the Notification Class object itself when deciding
  whether to forward an event.

  ### Examples (Doc Test)

  ```elixir
  iex> destination = %Destination{
  ...>   recipient: %Recipient{type: :device, device: %ObjectIdentifier{type: :device, instance: 123}, address: nil},
  ...>   process_identifier: 1,
  ...>   issue_confirmed_notifications: true,
  ...>   transitions: %EventTransitionBits{to_offnormal: true, to_fault: true, to_normal: true},
  ...>   valid_days: %DaysOfWeek{monday: true, tuesday: true, wednesday: true, thursday: true, friday: true, saturday: false, sunday: false},
  ...>   from_time: %BACnetTime{hour: 8, minute: 0, second: 0, hundredth: 0},
  ...>   to_time: %BACnetTime{hour: 18, minute: 0, second: 0, hundredth: 0}
  ...> }
  iex> destination.process_identifier
  1
  ```
  """

  # TODO: Throw argument error in encode if not valid

  alias BACnet.Protocol.ApplicationTags
  alias BACnet.Protocol.BACnetTime
  alias BACnet.Protocol.DaysOfWeek
  alias BACnet.Protocol.EventTransitionBits
  alias BACnet.Protocol.Recipient

  @typedoc """
  Represents the target and timing constraints for an event or COV notification.
  """
  @type t :: %__MODULE__{
          recipient: Recipient.t(),
          process_identifier: ApplicationTags.unsigned32(),
          issue_confirmed_notifications: boolean(),
          transitions: EventTransitionBits.t(),
          valid_days: DaysOfWeek.t(),
          from_time: BACnetTime.t(),
          to_time: BACnetTime.t()
        }

  @fields [
    :recipient,
    :process_identifier,
    :issue_confirmed_notifications,
    :transitions,
    :valid_days,
    :from_time,
    :to_time
  ]
  @enforce_keys @fields
  defstruct @fields

  @doc """
  Encodes a BACnet destination into BACnet application tags encoding.
  """
  @spec encode(t(), Keyword.t()) ::
          {:ok, ApplicationTags.encoding_list()} | {:error, term()}
  def encode(%__MODULE__{} = destination, _opts \\ []) do
    with :ok <-
           if(
             destination.process_identifier >= 0 and
               ApplicationTags.valid_int?(destination.process_identifier, 32),
             do: :ok,
             else: {:error, :invalid_process_identifier_value}
           ),
         {:ok, [recipient_raw]} <- Recipient.encode(destination.recipient) do
      params = [
        DaysOfWeek.to_bitstring(destination.valid_days),
        {:time, destination.from_time},
        {:time, destination.to_time},
        recipient_raw,
        {:unsigned_integer, destination.process_identifier},
        {:boolean, destination.issue_confirmed_notifications},
        EventTransitionBits.to_bitstring(destination.transitions)
      ]

      {:ok, params}
    end
  end

  @doc """
  Parses a BACnet destination from BACnet application tags encoding.
  """
  @spec parse(ApplicationTags.encoding_list()) ::
          {:ok, {t(), rest :: ApplicationTags.encoding_list()}}
          | {:error, term()}
  def parse(tags) when is_list(tags) do
    case tags do
      [
        {:bitstring, days},
        {:time, from_time},
        {:time, to_time},
        recipient_raw,
        {:unsigned_integer, process_id},
        {:boolean, issue_confirmed},
        {:bitstring, event_trans}
        | rest
      ] ->
        with :ok <-
               if(ApplicationTags.valid_int?(process_id, 32),
                 do: :ok,
                 else: {:error, :invalid_process_identifier_value}
               ),
             {:ok, {recipient, _rest}} <- Recipient.parse([recipient_raw]) do
          dest = %__MODULE__{
            recipient: recipient,
            process_identifier: process_id,
            issue_confirmed_notifications: issue_confirmed,
            transitions: EventTransitionBits.from_bitstring(event_trans),
            valid_days: DaysOfWeek.from_bitstring(days),
            from_time: from_time,
            to_time: to_time
          }

          {:ok, {dest, rest}}
        end

      _else ->
        {:error, :invalid_tags}
    end
  end

  @doc """
  Validates whether the given BACnet destination is in form valid.

  It only validates the struct is valid as per type specification.
  """
  @spec valid?(t()) :: boolean()
  def valid?(
        %__MODULE__{
          recipient: %Recipient{} = recip,
          process_identifier: process_id,
          issue_confirmed_notifications: issue_confirmed,
          transitions: %EventTransitionBits{} = trans,
          valid_days: %DaysOfWeek{} = days,
          from_time: %BACnetTime{} = from_t,
          to_time: %BACnetTime{} = to_t
        } = _t
      )
      when is_integer(process_id) and process_id >= 0 and process_id <= 4_294_967_295 and
             is_boolean(issue_confirmed) do
    Recipient.valid?(recip) and EventTransitionBits.valid?(trans) and DaysOfWeek.valid?(days) and
      BACnetTime.valid?(from_t) and BACnetTime.valid?(to_t) and BACnetTime.specific?(from_t) and
      BACnetTime.specific?(to_t)
  end

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