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

lib/bacnet/protocol/log_record.ex

defmodule BACnet.Protocol.LogRecord do
  @moduledoc """
  A Log Record is the basic unit stored inside the buffer of a Trend Log or
  Event Log object. Every time a trend log collects a sample, or an event log
  records an event notification, a Log Record is appended to the circular buffer.

  Each record carries a timestamp (which may be a full BACnet DateTime or a
  sequence number), a status field of the object, and then one of several possible
  value choices: a normal data value, a time change indication, a BACnet
  Error, or a special "log status" record.

  ### Examples (Doc Test)

  Basic real value record:

  ```elixir
  iex> record = %LogRecord{
  ...>   timestamp: %BACnet.Protocol.BACnetTimestamp{type: :datetime, time: nil, sequence_number: nil, datetime: %BACnet.Protocol.BACnetDateTime{date: %BACnet.Protocol.BACnetDate{year: 2025, month: 1, day: 1, weekday: 3}, time: %BACnet.Protocol.BACnetTime{hour: 12, minute: 0, second: 0, hundredth: 0}}},
  ...>   status_flags: %BACnet.Protocol.StatusFlags{in_alarm: false, fault: false, overridden: false, out_of_service: false},
  ...>   log_datum: {:real, 23.4}
  ...> }
  iex> record.log_datum
  {:real, 23.4}
  ```

  #### Different log_datum CHOICE arms

  ```elixir
  # Log status record (buffer purged, etc.)
  iex> status_rec = %LogRecord{
  ...>   timestamp: %BACnet.Protocol.BACnetTimestamp{type: :sequence_number, sequence_number: 42, time: nil, datetime: nil},
  ...>   status_flags: nil,
  ...>   log_datum: %LogStatus{log_disabled: false, buffer_purged: true, log_interrupted: false}
  ...> }
  iex> status_rec.log_datum.log_disabled
  false

  # Error record
  iex> %LogRecord{
  ...>   timestamp: %BACnet.Protocol.BACnetTimestamp{type: :datetime, time: nil, sequence_number: nil, datetime: %BACnet.Protocol.BACnetDateTime{date: %BACnet.Protocol.BACnetDate{year: 2025, month: 1, day: 1, weekday: 3}, time: %BACnet.Protocol.BACnetTime{hour: 0, minute: 0, second: 0, hundredth: 0}}},
  ...>   status_flags: nil,
  ...>   log_datum: %BACnet.Protocol.BACnetError{class: :services, code: :timeout}
  ...> }

  # Time change indication
  iex> %LogRecord{
  ...>   timestamp: %BACnet.Protocol.BACnetTimestamp{type: :sequence_number, sequence_number: 100, time: nil, datetime: nil},
  ...>   status_flags: nil,
  ...>   log_datum: {:time_change, 3600.0}
  ...> }
  ```

  Note: Different arms are encoded with different context tags (0 for LogStatus, 8 for Error, 9 for time change, etc.). The higher-level `encode/2` handles the tagging.
  """

  # TODO: Throw argument error in encode if not valid

  alias BACnet.Protocol.ApplicationTags
  alias BACnet.Protocol.BACnetDateTime
  alias BACnet.Protocol.BACnetError
  alias BACnet.Protocol.Constants
  alias BACnet.Protocol.LogStatus
  alias BACnet.Protocol.StatusFlags

  import BACnet.Protocol.Utility, only: [pattern_extract_tags: 4]

  @typedoc """
  A single record in a Trend Log or Event Log.
  """
  @type t :: %__MODULE__{
          timestamp: BACnetDateTime.t(),
          log_datum:
            LogStatus.t()
            | ApplicationTags.Encoding.t()
            | BACnetError.t()
            | {:time_change, float()}
            | nil,
          status_flags: StatusFlags.t() | nil
        }

  @fields [
    :timestamp,
    :log_datum,
    :status_flags
  ]
  @enforce_keys @fields
  defstruct @fields

  @doc """
  Encode a BACnet log record into application tag-encoded.
  """
  @spec encode(t(), Keyword.t()) ::
          {:ok, ApplicationTags.encoding_list()} | {:error, term()}
  def encode(%__MODULE__{} = record, opts \\ []) do
    with {:ok, log_datum} <- encode_log_datum(record.log_datum, opts),
         {:ok, status_flags, _header} <-
           (if record.status_flags do
              ApplicationTags.encode_value(StatusFlags.to_bitstring(record.status_flags), opts)
            else
              {:ok, nil, nil}
            end) do
      base = [
        {:constructed, {0, [date: record.timestamp.date, time: record.timestamp.time], 0}},
        {:constructed, {1, log_datum, 0}},
        {:tagged, {2, status_flags, byte_size(status_flags || <<>>)}}
      ]

      {:ok, Enum.filter(base, fn {_type, {_t, con, _l}} -> con end)}
    else
      {:error, _err} = err -> err
    end
  end

  @doc """
  Parse a BACnet log record from application tags encoding.
  """
  @spec parse(ApplicationTags.encoding_list()) ::
          {:ok, {t(), rest :: ApplicationTags.encoding_list()}}
          | {:error, term()}
  def parse(tags) when is_list(tags) do
    with {:ok,
          {:constructed,
           {0,
            [
              date: date,
              time: time
            ], _l}},
          rest} <-
           pattern_extract_tags(tags, {:constructed, {0, _t, _l}}, nil, false),
         {:ok, {:constructed, {1, log_datum_raw, _l}}, rest} <-
           pattern_extract_tags(rest, {:constructed, {1, _t, _l}}, nil, false),
         {:ok, log_datum} <- parse_log_datum(log_datum_raw),
         {:ok, status_flags_raw, rest} <-
           pattern_extract_tags(rest, {:tagged, {2, _t, _l}}, :bitstring, true),
         {:ok, {status_flags, _rest}} <-
           (if status_flags_raw do
              StatusFlags.parse([{:bitstring, status_flags_raw}])
            else
              {:ok, {nil, nil}}
            end) do
      record = %__MODULE__{
        timestamp: %BACnetDateTime{
          date: date,
          time: time
        },
        log_datum: log_datum,
        status_flags: status_flags
      }

      {:ok, {record, rest}}
    else
      {:ok, _tags, _rest} -> {:error, :invalid_tags}
      {:error, :missing_pattern} -> {:error, :invalid_tags}
      {:error, _err} = err -> err
    end
  end

  @doc """
  Validates whether the given log record is in form valid.

  It only validates the struct is valid as per type specification.
  """
  @spec valid?(t()) :: boolean()
  def valid?(t)

  def valid?(
        %__MODULE__{
          timestamp: %BACnetDateTime{} = stamp,
          log_datum: %LogStatus{} = log,
          status_flags: status_flags
        } = _t
      ) do
    BACnetDateTime.valid?(stamp) and LogStatus.valid?(log) and
      (is_nil(status_flags) or
         (is_struct(status_flags, StatusFlags) and StatusFlags.valid?(status_flags)))
  end

  def valid?(
        %__MODULE__{
          timestamp: %BACnetDateTime{} = stamp,
          log_datum: %BACnetError{} = log,
          status_flags: status_flags
        } = _t
      ) do
    BACnetDateTime.valid?(stamp) and BACnetError.valid?(log) and
      (is_nil(status_flags) or
         (is_struct(status_flags, StatusFlags) and StatusFlags.valid?(status_flags)))
  end

  def valid?(
        %__MODULE__{
          timestamp: %BACnetDateTime{} = stamp,
          log_datum: {:time_change, value},
          status_flags: status_flags
        } = _t
      )
      when is_float(value) do
    BACnetDateTime.valid?(stamp) and
      (is_nil(status_flags) or
         (is_struct(status_flags, StatusFlags) and StatusFlags.valid?(status_flags)))
  end

  def valid?(
        %__MODULE__{
          timestamp: %BACnetDateTime{} = stamp,
          log_datum: nil,
          status_flags: status_flags
        } = _t
      ) do
    BACnetDateTime.valid?(stamp) and
      (is_nil(status_flags) or
         (is_struct(status_flags, StatusFlags) and StatusFlags.valid?(status_flags)))
  end

  def valid?(
        %__MODULE__{
          timestamp: %BACnetDateTime{} = stamp,
          log_datum: %ApplicationTags.Encoding{type: type},
          status_flags: status_flags
        } = _t
      )
      when type in [
             :boolean,
             :real,
             :enumerated,
             :unsigned_integer,
             :signed_integer,
             :bitstring
           ] do
    BACnetDateTime.valid?(stamp) and
      (is_nil(status_flags) or
         (is_struct(status_flags, StatusFlags) and StatusFlags.valid?(status_flags)))
  end

  def valid?(
        %__MODULE__{
          timestamp: %BACnetDateTime{} = stamp,
          log_datum: %ApplicationTags.Encoding{encoding: :constructed} = enc,
          status_flags: status_flags
        } = _t
      ) do
    BACnetDateTime.valid?(stamp) and
      Keyword.get(enc.extras, :tag_number) == 10 and
      (is_nil(status_flags) or
         (is_struct(status_flags, StatusFlags) and StatusFlags.valid?(status_flags)))
  end

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

  @spec parse_log_datum(ApplicationTags.encoding()) ::
          {:ok, term()} | {:error, term()}
  defp parse_log_datum(tags)

  defp parse_log_datum([head | _tl]) do
    parse_log_datum(head)
  end

  defp parse_log_datum({:tagged, {0, _tags, _len}} = tags) do
    with {:ok, {:bitstring, _bits} = bits} <- ApplicationTags.unfold_to_type(:bitstring, tags),
         {:ok, {log, _rest}} <- LogStatus.parse([bits]) do
      {:ok, log}
    else
      {:error, _err} = err -> err
    end
  end

  defp parse_log_datum({_type, {tagnum, _tags, _len}} = tags)
       when tagnum in [1, 2, 3, 4, 5, 6, 10] do
    cast =
      case tagnum do
        1 -> :boolean
        2 -> :real
        3 -> :enumerated
        4 -> :unsigned_integer
        5 -> :signed_integer
        6 -> :bitstring
        10 -> nil
      end

    ApplicationTags.Encoding.create(tags, cast_type: cast)
  end

  defp parse_log_datum({:tagged, {7, _tags, _len}}) do
    {:ok, nil}
  end

  defp parse_log_datum({:constructed, {8, tags, 0}}) do
    case tags do
      [
        enumerated: class,
        enumerated: code
      ] ->
        error = %BACnetError{
          class: Constants.by_value(:error_class, class, class),
          code: Constants.by_value(:error_code, code, code)
        }

        {:ok, error}

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

  defp parse_log_datum({:tagged, {9, _tags, _len}} = tags) do
    case ApplicationTags.unfold_to_type(:real, tags) do
      {:ok, {:real, real}} ->
        {:ok, {:time_change, real}}

      {:error, _err} = err ->
        err
    end
  end

  defp parse_log_datum(_tags), do: {:error, :invalid_tags}

  @spec encode_log_datum(term(), Keyword.t()) ::
          {:ok, ApplicationTags.encoding()} | {:error, term()}
  defp encode_log_datum(value, opts)

  defp encode_log_datum(%LogStatus{} = value, _opts) do
    ApplicationTags.create_tag_encoding(0, LogStatus.to_bitstring(value))
  end

  defp encode_log_datum(%ApplicationTags.Encoding{type: type, value: value}, opts)
       when type in [:boolean, :real, :enumerated, :unsigned_integer, :signed_integer, :bitstring] do
    enc_type =
      if type == :boolean do
        :unsigned_integer
      else
        type
      end

    with {:ok, encoding, _header} <- ApplicationTags.encode_value({enc_type, value}, opts) do
      tagnum =
        case type do
          :boolean -> 1
          :real -> 2
          :enumerated -> 3
          :unsigned_integer -> 4
          :signed_integer -> 5
          :bitstring -> 6
        end

      {:ok, {:tagged, {tagnum, encoding, byte_size(encoding)}}}
    end
  end

  defp encode_log_datum(
         %ApplicationTags.Encoding{encoding: :primitive, value: nil} = _value,
         opts
       ) do
    encode_log_datum(nil, opts)
  end

  defp encode_log_datum(%ApplicationTags.Encoding{} = value, _opts) do
    case ApplicationTags.Encoding.to_encoding(value) do
      {:ok, {:constructed, _val}} = val -> val
      {:ok, encoding} -> {:ok, {:constructed, {10, encoding, 0}}}
      term -> term
    end
  end

  defp encode_log_datum(nil, _opts) do
    {:ok, {:tagged, {7, <<>>, 0}}}
  end

  defp encode_log_datum(%BACnetError{} = value, _opts) do
    with {:ok, error} <- BACnetError.encode(value) do
      {:ok, {:constructed, {8, error, 0}}}
    end
  end

  defp encode_log_datum({:time_change, value}, _opts) do
    ApplicationTags.create_tag_encoding(9, :real, value)
  end

  defp encode_log_datum(_data, _opts), do: {:error, :invalid_log_datum}
end