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

lib/bacnet/protocol/npci.ex

defmodule BACnet.Protocol.NPCI do
  @moduledoc """
  Network Protocol Control Information (NPCI) is the header that precedes every
  BACnet Network Protocol Data Unit (NPDU). It carries the information that
  BACnet routers and receiving devices need to correctly forward or deliver the
  message.

  The NPCI indicates the version of the protocol, the ultimate source and
  destination networks (when the message crosses routers), whether a reply is
  expected, the priority of the message, and whether the data that follows is
  an Application Protocol Data Unit (APDU) or a Network Service Data Unit
  (NSDU). Network-layer messages (NSDUs) are used for router-to-router
  communication, router configuration, and certain network management functions.
  """

  alias BACnet.Protocol.Constants
  alias BACnet.Protocol.NpciTarget

  require Constants

  @typedoc """
  Represents Network Protocol Control Information (NPCI).
  """
  @type t :: %__MODULE__{
          priority: Constants.npdu_control_priority(),
          expects_reply: boolean(),
          destination: NpciTarget.t() | nil,
          source: NpciTarget.t() | nil,
          hopcount: non_neg_integer() | nil,
          is_network_message: boolean()
        }

  @fields [
    :priority,
    :expects_reply,
    :destination,
    :source,
    :hopcount,
    :is_network_message
  ]
  @enforce_keys @fields
  defstruct @fields

  @npci_version 0x01

  @doc """
  Get the NPCI version.
  """
  @spec get_version() :: non_neg_integer()
  def get_version(), do: @npci_version

  @doc """
  Creates a new NPCI struct with the given fields.

  The following default values are applied:
  ```ex
  priority: :normal,
  expects_reply: false,
  destination: nil,
  source: nil,
  hopcount: nil,
  is_network_message: false
  ```
  """
  @spec new(Keyword.t()) :: t()
  def new(fields) when is_list(fields) do
    Enum.reduce(
      fields,
      %__MODULE__{
        priority: :normal,
        expects_reply: false,
        destination: nil,
        source: nil,
        hopcount: nil,
        is_network_message: false
      },
      fn
        {:priority, value}, acc
        when value in Constants.macro_list_names(:npdu_control_priority) ->
          %{acc | priority: value}

        {:expects_reply, value}, acc when is_boolean(value) ->
          %{acc | expects_reply: value}

        {:destination, value}, acc
        when is_nil(value) or (is_struct(value, NpciTarget) and value.net in 1..65_535) ->
          %{acc | destination: value}

        {:source, value}, acc
        when is_nil(value) or (is_struct(value, NpciTarget) and value.net in 1..65_534) ->
          %{acc | source: value}

        {:hopcount, value}, acc when is_nil(value) or value in 1..255 ->
          %{acc | hopcount: value}

        {:is_network_message, value}, acc when is_boolean(value) ->
          %{acc | is_network_message: value}

        {field, value}, _acc when field in @fields ->
          raise ArgumentError, "Invalid value for field #{field}, got: #{inspect(value)}"

        term, _acc ->
          raise ArgumentError, "Unknown or invalid term, got: #{inspect(term)}"
      end
    )
  end

  @doc """
  Creates a NPCI iodata from the NPCI struct.

  If `destination` is not nil, but `net` is nil, `net` will default to `1`.
  """
  @spec encode(t(), Keyword.t()) :: iodata()
  def encode(%__MODULE__{} = npci, _opts \\ []) do
    priority = Constants.by_name!(:npdu_control_priority, npci.priority)

    {destination_specifier, destination} =
      case npci.destination do
        %NpciTarget{} = target ->
          npci_target(npci, target)

        nil ->
          {0, <<>>}

        term ->
          raise ArgumentError,
                "Invalid destination, expected nil or NpciTarget, got: #{inspect(term)}"
      end

    {source_specifier, source} =
      case npci.source do
        %NpciTarget{net: 65_535} ->
          raise ArgumentError, "Invalid source, net 65535 is not allowed"

        %NpciTarget{address: nil} ->
          raise ArgumentError, "Invalid source, address nil is not allowed"

        %NpciTarget{} = target ->
          npci_target(npci, target)

        nil ->
          {0, <<>>}

        term ->
          raise ArgumentError, "Invalid source, expected nil or NpciTarget, got: #{inspect(term)}"
      end

    hopcount =
      if destination_specifier == 1 do
        hop = if is_integer(npci.hopcount), do: min(max(npci.hopcount, 1), 255), else: 255
        <<hop::size(8)>>
      else
        <<>>
      end

    # Hint: res. = reserved
    # npci = apdu_0_or_npdu_1, res., destination_specifier, res., source_specifier, expects_reply, priority_two_bits
    npci =
      <<@npci_version::size(8), intify(npci.is_network_message)::size(1), 0::size(1),
        destination_specifier::size(1), 0::size(1), source_specifier::size(1),
        intify(npci.expects_reply)::size(1), priority::size(2)>>

    [
      npci,
      destination,
      source,
      hopcount
    ]
  end

  @spec intify(boolean()) :: 0..1
  defp intify(true), do: 1
  defp intify(false), do: 0

  @spec npci_target(t(), NpciTarget.t()) :: {0..1, binary()}
  defp npci_target(%__MODULE__{} = _npci, %NpciTarget{} = target) do
    if target.net == 0 or target.net == nil do
      raise ArgumentError, "Invalid net, must be in the range of 1..65535"
    end

    {len, addr_bin} =
      case target.address do
        nil ->
          {0, <<>>}

        # {ipaddr, port}
        # when is_tuple(ipaddr) and tuple_size(ipaddr) == 4 and port in 1..65_535 ->
        #   addr =
        #     ipaddr
        #     |> Tuple.to_list()
        #     |> List.to_string()
        #
        #   finaddr = <<addr::binary, port::size(16)>>
        #
        #   {byte_size(finaddr), finaddr}
        #
        # netaddr when is_tuple(netaddr) when tuple_size(netaddr) in 1..6 ->
        #   addr =
        #     netaddr
        #     |> Tuple.to_list()
        #     |> List.to_string()
        #
        #   {byte_size(addr), addr}
        #
        # netaddr when netaddr in 0..255 ->
        #   {1, <<netaddr::size(8)>>}

        netaddr when is_integer(netaddr) and netaddr >= 0 and netaddr <= 72_057_594_037_927_935 ->
          int_length = div(byte_size(Integer.to_string(netaddr, 2)) + 7, 8)
          bin = <<netaddr::integer-size(int_length)-unit(8)>>
          {int_length, bin}

        term ->
          raise ArgumentError,
                "Invalid destination address, expected nil or " <>
                  "a positive integer with max. 48 bits set, " <>
                  "got: #{inspect(term)}"
      end

    net = target.net || 1
    addr = <<net::size(16), len::size(8), addr_bin::binary>>

    {1, addr}
  end
end