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

lib/bacnet/protocol/bvlc_function.ex

defmodule BACnet.Protocol.BvlcFunction do
  @moduledoc """
  A BVLC Function is the payload of a BACnet Virtual Link Layer (BVLL) message
  used on BACnet/IP networks (ASHRAE 135 Annex J). Each function code corresponds
  to a different operation: Register-Foreign-Device, Read-Broadcast-Distribution-Table,
  Write-Broadcast-Distribution-Table, and several others used for BBMD and
  Foreign Device management.

  This module models the seven management BVLC functions that carry structured
  payloads (as opposed to the five simple NPDU-carrier functions that are
  represented as atoms in `t:BACnet.Protocol.bvlc/0`). It is the central "CHOICE"
  type for BVLC in the library: the `function` field (an atom from the
  `:bvlc_result_purpose` constants) acts as the discriminant and determines the
  shape of the `data` field.

  ## BACnet Specification References

  - Annex J.2 defines the BACnet Virtual Link Layer (BVLL) and the BVLC Function
    field (1 octet) together with the overall message format (Type 0x81, Length,
    Function, Data).
  - J.2.2-J.2.9 give the exact purpose and octet layout for each management
    function handled here (Write-BDT 0x01, Read-BDT 0x02, Read-BDT-Ack 0x03,
    Register-Foreign-Device 0x05, Read-FDT 0x06, Read-FDT-Ack 0x07,
    Delete-FDT-Entry 0x08).
  - J.2.1 and J.2.1.1 define the companion BVLC-Result (0x00) and its result codes
    (see `BACnet.Protocol.BvlcResult` and the `:bvlc_result_format` constants).
  - J.4.3-J.4.5 and J.5 describe how a BBMD uses these functions to maintain the
    Broadcast Distribution Table (BDT) and Foreign Device Table (FDT) and to
    forward broadcasts.

  ## Supported BVLC Functions and Data Shapes

  Only the functions listed below are decoded/encoded by this module. All other
  function codes (including the five NPDU carriers and Secure-BVLL 0x0C) are
  handled elsewhere or return `{:error, :unsupported_bvlc_function}`.

  | Function Atom                                 | Code | `data` Shape                                                                          | Spec Reference |
  |-----------------------------------------------|------|---------------------------------------------------------------------------------------|----------------|
  | `:bvlc_write_broadcast_distribution_table`    | 0x01 | `[BroadcastDistributionTableEntry.t()]`                                               | J.2.2          |
  | `:bvlc_read_broadcast_distribution_table`     | 0x02 | `nil`                                                                                 | J.2.3          |
  | `:bvlc_read_broadcast_distribution_table_ack` | 0x03 | `[BroadcastDistributionTableEntry.t()]`                                               | J.2.4          |
  | `:bvlc_register_foreign_device`               | 0x05 | `non_neg_integer()` (TTL in seconds, 1..65535)                                        | J.2.6          |
  | `:bvlc_read_foreign_device_table`             | 0x06 | `nil`                                                                                 | J.2.7          |
  | `:bvlc_read_foreign_device_table_ack`         | 0x07 | `[ForeignDeviceTableEntry.t()]`                                                       | J.2.8          |
  | `:bvlc_delete_foreign_device_table_entry`     | 0x08 | `ForeignDeviceTableEntry.t()` (with `time_to_live` and `remaining_time` set to `nil`) | J.2.9          |

  ## Construction & Gotchas

      iex> alias BACnet.Protocol.{BvlcFunction, Constants, ForeignDeviceTableEntry}
      iex> require Constants
      iex> ttl = 3600
      iex> fun = %BvlcFunction{
      ...>   function: Constants.macro_assert_name(:bvlc_result_purpose, :bvlc_register_foreign_device),
      ...>   data: ttl
      ...> }
      iex> fun.data
      3600

  Special cases you must know:

  - Read requests (0x02, 0x06) and their "no data" paths always use `data: nil`.
  - Delete-Foreign-Device-Table-Entry (0x08) re-uses the `BACnet.Protocol.ForeignDeviceTableEntry`
    struct but **must** have both `time_to_live` and `remaining_time` set to `nil`.
    The encoder produces the short 6-octet form required by J.2.9.1. See
    `BACnet.Protocol.ForeignDeviceTableEntry` for the exact encoding rules.
  - Secure-BVLL (0x0C) and the five simple NPDU functions (0x00 result, 0x04
    forwarded, 0x09 distribute, 0x0A/0x0B original) are **never** represented as
    a `BACnet.Protocol.BvlcFunction` struct. They appear as `BACnet.Protocol.BvlcResult`, `BACnet.Protocol.BvlcForwardedNPDU` or
    atoms inside `t:BACnet.Protocol.bvlc/0`.
  - The library does not implement Secure-BVLL (Clause 24) or IPv6 BVLL (Type
    0x82). Attempting to decode those codes returns `{:error, :unsupported_bvlc_function}`.

  ### Examples

  Register-Foreign-Device (TTL = 3600 s):

      iex> alias BACnet.Protocol.{BvlcFunction, Constants}
      iex> require Constants
      iex> fun = %BvlcFunction{
      ...>   function: Constants.macro_assert_name(:bvlc_result_purpose, :bvlc_register_foreign_device),
      ...>   data: 3600
      ...> }
      iex> BvlcFunction.encode(fun)
      {:ok, {5, <<14, 16>>}}

  Write-Broadcast-Distribution-Table with a single entry (two-hop mask):

      iex> alias BACnet.Protocol.{BvlcFunction, Constants, BroadcastDistributionTableEntry}
      iex> require Constants
      iex> entry = %BroadcastDistributionTableEntry{
      ...>   ip: {192, 168, 1, 10},
      ...>   port: 0xBAC0,
      ...>   mask: {255, 255, 255, 255}
      ...> }
      iex> fun = %BvlcFunction{
      ...>   function: Constants.macro_assert_name(:bvlc_result_purpose, :bvlc_write_broadcast_distribution_table),
      ...>   data: [entry]
      ...> }
      iex> BvlcFunction.encode(fun)
      {:ok, {1, <<192, 168, 1, 10, 186, 192, 255, 255, 255, 255>>}}

  Delete-Foreign-Device-Table-Entry (note the `nil` fields producing the short 6-octet form):

      iex> alias BACnet.Protocol.{BvlcFunction, Constants, ForeignDeviceTableEntry}
      iex> require Constants
      iex> del = %ForeignDeviceTableEntry{
      ...>   ip: {10, 0, 0, 5},
      ...>   port: 0xBAC0,
      ...>   time_to_live: nil,
      ...>   remaining_time: nil
      ...> }
      iex> fun = %BvlcFunction{
      ...>   function: Constants.macro_assert_name(:bvlc_result_purpose, :bvlc_delete_foreign_device_table_entry),
      ...>   data: del
      ...> }
      iex> BvlcFunction.encode(fun)
      {:ok, {8, <<10, 0, 0, 5, 186, 192>>}}

  ## Usage Contexts

  `BACnet.Protocol.BvlcFunction` values are produced by `BACnet.Protocol.decode_bvll/3` when the
  incoming BVLC function code is one of the seven management values. They are
  primarily consumed by `BACnet.Stack.BBMD` (which answers Read-BDT/Read-FDT,
  accepts Write-BDT and Register-Foreign-Device, and emits the corresponding Ack
  or Result messages) and by `BACnet.Stack.ForeignDevice` (which emits Register,
  Read-BDT, Read-FDT and Distribute-Broadcast requests).

  The encode side is used when a client or BBMD needs to emit a management
  request or response over a `BACnet.Stack.Transport.IPv4Transport`.

  ## See Also

  - `BACnet.Protocol` - top-level `bvlc()` union type and `decode_bvll/3` dispatcher
  - `BACnet.Protocol.BroadcastDistributionTableEntry` - rows stored in BDT payloads
  - `BACnet.Protocol.BvlcForwardedNPDU` - the Forwarded-NPDU carrier (0x04)
  - `BACnet.Protocol.BvlcResult` - companion result/NAK message (function 0x00)
  - `BACnet.Protocol.ForeignDeviceTableEntry` - rows stored in FDT payloads (and the special nil form used for Delete)
  - `BACnet.Stack.BBMD` - primary consumer that maintains BDT/FDT using these functions
  - `BACnet.Stack.ForeignDevice` - client-side registration and table reading
  """

  # Register-Foreign-Device data is the TTL

  alias BACnet.Protocol.BroadcastDistributionTableEntry
  alias BACnet.Protocol.Constants
  alias BACnet.Protocol.ForeignDeviceTableEntry

  require Constants

  @typedoc """
  Represents a BACnet Virtual Link Control (BVLC) function used in BACnet/IP.

  The `function` field is the discriminant. Its value determines the required
  shape of `data` (see the moduledoc table). Use the
  `BACnet.Protocol.Constants.macro_assert_name/2` helpers when constructing
  values for maximum safety and readability.
  """
  @type t :: %__MODULE__{
          function: Constants.bvlc_result_purpose(),
          data:
            [BroadcastDistributionTableEntry.t()]
            | [ForeignDeviceTableEntry.t()]
            | (delete_foreign_device_table_entry :: ForeignDeviceTableEntry.t())
            | (read_broadcast_distribution_table :: nil)
            | (read_foreign_device_table :: nil)
            | (register_foreign_device :: non_neg_integer())
        }

  @fields [:function, :data]
  @enforce_keys @fields
  defstruct @fields

  @doc """
  Decodes BACnet Virtual Link Control Functions into a struct.

  Supported are the following BVLC functions:
  - Delete-Foreign-Device-Table-Entry
  - Read-Broadcast-Distribution-Table
  - Read-Broadcast-Distribution-Table-Ack
  - Read-Foreign-Device-Table
  - Read-Foreign-Device-Table-Ack
  - Register-Foreign-Device
  - Write-Broadcast-Distribution-Table
  """
  @spec decode(non_neg_integer(), binary()) :: {:ok, t()} | {:error, term()}
  def decode(bvlc_function, data)

  def decode(
        Constants.macro_by_name(:bvlc_result_purpose, :bvlc_write_broadcast_distribution_table),
        data
      )
      when is_binary(data) do
    with {:ok, bdt} <-
           Enum.reduce_while(1..65_535//1, {data, []}, fn
             _entry, {<<>>, acc} ->
               {:halt, {:ok, Enum.reverse(acc)}}

             _entry, {data, acc} ->
               case BroadcastDistributionTableEntry.decode(data) do
                 {:ok, {bdt, rest}} -> {:cont, {rest, [bdt | acc]}}
                 term -> {:halt, term}
               end
           end) do
      fun = %__MODULE__{
        function:
          Constants.macro_assert_name(
            :bvlc_result_purpose,
            :bvlc_write_broadcast_distribution_table
          ),
        data: bdt
      }

      {:ok, fun}
    end
  end

  def decode(
        Constants.macro_by_name(:bvlc_result_purpose, :bvlc_read_broadcast_distribution_table),
        <<>>
      ) do
    fun = %__MODULE__{
      function:
        Constants.macro_assert_name(:bvlc_result_purpose, :bvlc_read_broadcast_distribution_table),
      data: nil
    }

    {:ok, fun}
  end

  def decode(
        Constants.macro_by_name(:bvlc_result_purpose, :bvlc_read_broadcast_distribution_table),
        data
      )
      when is_binary(data) do
    {:error, :invalid_data}
  end

  def decode(
        Constants.macro_by_name(
          :bvlc_result_purpose,
          :bvlc_read_broadcast_distribution_table_ack
        ),
        data
      )
      when is_binary(data) do
    with {:ok, bdt} <-
           Enum.reduce_while(1..65_535//1, {data, []}, fn
             _entry, {<<>>, acc} ->
               {:halt, {:ok, Enum.reverse(acc)}}

             _entry, {data, acc} ->
               case BroadcastDistributionTableEntry.decode(data) do
                 {:ok, {bdt, rest}} -> {:cont, {rest, [bdt | acc]}}
                 term -> {:halt, term}
               end
           end) do
      fun = %__MODULE__{
        function:
          Constants.macro_assert_name(
            :bvlc_result_purpose,
            :bvlc_read_broadcast_distribution_table_ack
          ),
        data: bdt
      }

      {:ok, fun}
    end
  end

  def decode(
        Constants.macro_by_name(:bvlc_result_purpose, :bvlc_register_foreign_device),
        <<ttl::size(16)>>
      ) do
    fun = %__MODULE__{
      function: Constants.macro_assert_name(:bvlc_result_purpose, :bvlc_register_foreign_device),
      data: ttl
    }

    {:ok, fun}
  end

  def decode(Constants.macro_by_name(:bvlc_result_purpose, :bvlc_register_foreign_device), data)
      when is_binary(data) do
    {:error, :invalid_data}
  end

  def decode(
        Constants.macro_by_name(:bvlc_result_purpose, :bvlc_read_foreign_device_table),
        <<>>
      ) do
    fun = %__MODULE__{
      function:
        Constants.macro_assert_name(:bvlc_result_purpose, :bvlc_read_foreign_device_table),
      data: nil
    }

    {:ok, fun}
  end

  def decode(
        Constants.macro_by_name(:bvlc_result_purpose, :bvlc_read_foreign_device_table),
        data
      )
      when is_binary(data) do
    {:error, :invalid_data}
  end

  def decode(
        Constants.macro_by_name(
          :bvlc_result_purpose,
          :bvlc_read_foreign_device_table_ack
        ),
        data
      )
      when is_binary(data) do
    with {:ok, fdt} <-
           Enum.reduce_while(1..65_535//1, {data, []}, fn
             _entry, {<<>>, acc} ->
               {:halt, {:ok, Enum.reverse(acc)}}

             _entry, {data, acc} ->
               case ForeignDeviceTableEntry.decode(data) do
                 {:ok, {fdt, rest}} -> {:cont, {rest, [fdt | acc]}}
                 term -> {:halt, term}
               end
           end) do
      fun = %__MODULE__{
        function:
          Constants.macro_assert_name(
            :bvlc_result_purpose,
            :bvlc_read_foreign_device_table_ack
          ),
        data: fdt
      }

      {:ok, fun}
    end
  end

  def decode(
        Constants.macro_by_name(:bvlc_result_purpose, :bvlc_delete_foreign_device_table_entry),
        <<ip_a, ip_b, ip_c, ip_d, ip_port::size(16)>>
      ) do
    fun = %__MODULE__{
      function:
        Constants.macro_assert_name(:bvlc_result_purpose, :bvlc_delete_foreign_device_table_entry),
      data: %ForeignDeviceTableEntry{
        ip: {ip_a, ip_b, ip_c, ip_d},
        port: ip_port,
        time_to_live: nil,
        remaining_time: nil
      }
    }

    {:ok, fun}
  end

  def decode(
        Constants.macro_by_name(:bvlc_result_purpose, :bvlc_delete_foreign_device_table_entry),
        data
      )
      when is_binary(data) do
    {:error, :invalid_data}
  end

  def decode(bvlc_function, data) when is_integer(bvlc_function) and is_binary(data) do
    {:error, :unsupported_bvlc_function}
  end

  @doc """
  Encodes BACnet Virtual Link Control Functions into binary data.

  Supported are the following BVLC functions:
  - Delete-Foreign-Device-Table-Entry
  - Read-Broadcast-Distribution-Table
  - Read-Broadcast-Distribution-Table-Ack
  - Read-Foreign-Device-Table
  - Read-Foreign-Device-Table-Ack
  - Register-Foreign-Device
  - Write-Broadcast-Distribution-Table
  """
  @spec encode(t()) ::
          {:ok, {bvlc_function :: non_neg_integer(), data :: binary()}} | {:error, term()}
  def encode(bvlc)

  def encode(%__MODULE__{
        function:
          Constants.macro_assert_name(
            :bvlc_result_purpose,
            :bvlc_write_broadcast_distribution_table
          ),
        data: bdt
      })
      when is_list(bdt) do
    with {:ok, bdtl} <-
           Enum.reduce_while(bdt, {:ok, <<>>}, fn
             bd, {:ok, acc} ->
               case BroadcastDistributionTableEntry.encode(bd) do
                 {:ok, bin} -> {:cont, {:ok, <<acc::binary, bin::binary>>}}
                 term -> {:halt, term}
               end
           end) do
      {:ok,
       {Constants.macro_by_name(:bvlc_result_purpose, :bvlc_write_broadcast_distribution_table),
        bdtl}}
    end
  end

  def encode(%__MODULE__{
        function:
          Constants.macro_assert_name(
            :bvlc_result_purpose,
            :bvlc_write_broadcast_distribution_table
          )
      }) do
    {:error, :invalid_data}
  end

  def encode(%__MODULE__{
        function:
          Constants.macro_assert_name(
            :bvlc_result_purpose,
            :bvlc_read_broadcast_distribution_table
          ),
        data: nil
      }) do
    {:ok,
     {Constants.macro_by_name(:bvlc_result_purpose, :bvlc_read_broadcast_distribution_table),
      <<>>}}
  end

  def encode(%__MODULE__{
        function:
          Constants.macro_assert_name(
            :bvlc_result_purpose,
            :bvlc_read_broadcast_distribution_table
          )
      }) do
    {:error, :invalid_data}
  end

  def encode(%__MODULE__{
        function:
          Constants.macro_assert_name(
            :bvlc_result_purpose,
            :bvlc_read_broadcast_distribution_table_ack
          ),
        data: bdt
      })
      when is_list(bdt) do
    with {:ok, bdtl} <-
           Enum.reduce_while(bdt, {:ok, <<>>}, fn
             bd, {:ok, acc} ->
               case BroadcastDistributionTableEntry.encode(bd) do
                 {:ok, bin} -> {:cont, {:ok, <<acc::binary, bin::binary>>}}
                 term -> {:halt, term}
               end
           end) do
      {:ok,
       {Constants.macro_by_name(
          :bvlc_result_purpose,
          :bvlc_read_broadcast_distribution_table_ack
        ), bdtl}}
    end
  end

  def encode(%__MODULE__{
        function:
          Constants.macro_assert_name(
            :bvlc_result_purpose,
            :bvlc_read_broadcast_distribution_table_ack
          )
      }) do
    {:error, :invalid_data}
  end

  def encode(%__MODULE__{
        function:
          Constants.macro_assert_name(
            :bvlc_result_purpose,
            :bvlc_register_foreign_device
          ),
        data: ttl
      })
      when is_integer(ttl) do
    {:ok,
     {Constants.macro_by_name(:bvlc_result_purpose, :bvlc_register_foreign_device),
      <<ttl::size(16)>>}}
  end

  def encode(%__MODULE__{
        function:
          Constants.macro_assert_name(
            :bvlc_result_purpose,
            :bvlc_register_foreign_device
          )
      }) do
    {:error, :invalid_data}
  end

  def encode(%__MODULE__{
        function:
          Constants.macro_assert_name(
            :bvlc_result_purpose,
            :bvlc_read_foreign_device_table
          ),
        data: nil
      }) do
    {:ok, {Constants.macro_by_name(:bvlc_result_purpose, :bvlc_read_foreign_device_table), <<>>}}
  end

  def encode(%__MODULE__{
        function:
          Constants.macro_assert_name(
            :bvlc_result_purpose,
            :bvlc_read_foreign_device_table
          )
      }) do
    {:error, :invalid_data}
  end

  def encode(%__MODULE__{
        function:
          Constants.macro_assert_name(
            :bvlc_result_purpose,
            :bvlc_read_foreign_device_table_ack
          ),
        data: fdt
      })
      when is_list(fdt) do
    with {:ok, fdtl} <-
           Enum.reduce_while(fdt, {:ok, <<>>}, fn
             fd, {:ok, acc} ->
               case ForeignDeviceTableEntry.encode(fd) do
                 {:ok, bin} -> {:cont, {:ok, <<acc::binary, bin::binary>>}}
                 term -> {:halt, term}
               end
           end) do
      {:ok,
       {Constants.macro_by_name(:bvlc_result_purpose, :bvlc_read_foreign_device_table_ack), fdtl}}
    end
  end

  def encode(%__MODULE__{
        function:
          Constants.macro_assert_name(
            :bvlc_result_purpose,
            :bvlc_read_foreign_device_table_ack
          )
      }) do
    {:error, :invalid_data}
  end

  def encode(%__MODULE__{
        function:
          Constants.macro_assert_name(
            :bvlc_result_purpose,
            :bvlc_delete_foreign_device_table_entry
          ),
        data: %ForeignDeviceTableEntry{ip: {ip_a, ip_b, ip_c, ip_d}, port: port}
      })
      when is_integer(port) do
    {:ok,
     {Constants.macro_by_name(:bvlc_result_purpose, :bvlc_delete_foreign_device_table_entry),
      <<ip_a, ip_b, ip_c, ip_d, port::size(16)>>}}
  end

  def encode(%__MODULE__{
        function:
          Constants.macro_assert_name(
            :bvlc_result_purpose,
            :bvlc_delete_foreign_device_table_entry
          )
      }) do
    {:error, :invalid_data}
  end

  def encode(%__MODULE__{}), do: {:error, :unsupported_bvlc_function}
end