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

lib/bacnet/protocol/bacnet_timestamp.ex

defmodule BACnet.Protocol.BACnetTimestamp do
  @moduledoc """
  A BACnet Timestamp is a CHOICE type that can represent three different notions
  of "when something happened". The most common form is a full BACnet Date and
  Time. For trend logs that produce very high-frequency samples, a simple
  sequence number is often used instead because it consumes far less space and
  avoids the cost of maintaining a real-time clock with sub-second precision.
  The third form (Time only) is used when only the time of day is significant.

  Timestamps appear in Event Timestamps, in Trend Log records, in the Time Of
  Device Restart property, in logging notifications, and in many of the
  "last changed" properties throughout the object model. Because the three
  alternative encodings have very different sizes and precision characteristics,
  the choice of which form to use is often an important implementation decision
  that affects both memory usage and the ability of receiving systems to
  correlate events across devices.

  The type is defined so that a recipient can always determine which of the
  three alternatives is present without any additional context, which makes
  it safe to forward or store timestamps in generic log buffers and history
  archives.

  ### BACnet Specification References

  - **ASN.1** (Clause 21):
    ```
    BACnetTimeStamp ::= CHOICE {
        time            [0] Time,
        sequence-number [1] Unsigned (0..65535),
        dateTime        [2] BACnetDateTime
    }
    ```
  - **Encoding** (20.2.18): When context-tagged the choice is indicated by the
    context tag number (0 = time-only, 1 = sequence number, 2 = date+time).
    The contained value uses its normal application or constructed encoding.
  - **Usage drivers**: Trend Log (high-frequency samples favour sequence numbers),
    Event Timestamps property (all three forms allowed), `time_of_device_restart`.

  ### Examples

  #### Sequence number timestamp (common in Trend Logs)

  ```elixir
  iex> ts = %BACnetTimestamp{type: :sequence_number, sequence_number: 42837, time: nil, datetime: nil}
  iex> ts.sequence_number
  42837
  ```

  #### Full DateTime timestamp

  ```elixir
  iex> ts = %BACnetTimestamp{
  ...>   type: :datetime,
  ...>   sequence_number: nil,
  ...>   time: nil,
  ...>   datetime: %BACnetDateTime{
  ...>     date: %BACnetDate{year: 2025, month: 4, day: 1, weekday: 2},
  ...>     time: %BACnetTime{hour: 9, minute: 15, second: 0, hundredth: 0}
  ...>   }
  ...> }
  iex> ts.type
  :datetime
  ```

  #### Time-only timestamp

  ```elixir
  iex> ts = %BACnetTimestamp{type: :time, time: %BACnetTime{hour: 17, minute: 0, second: 0, hundredth: 0}, sequence_number: nil, datetime: nil}
  iex> ts.type
  :time
  ```

  ### See Also
  - `BACnet.Protocol.BACnetDate`
  - `BACnet.Protocol.BACnetDateTime`
  - `BACnet.Protocol.BACnetTime`
  - `BACnet.Protocol.EventTimestamps`
  - `BACnet.Protocol.LogRecord`
  """

  # TODO: Throw argument error in encode if not valid

  alias BACnet.Protocol.ApplicationTags
  alias BACnet.Protocol.BACnetDateTime
  alias BACnet.Protocol.BACnetTime

  @typedoc """
  A CHOICE timestamp (see ASN.1 `BACnetTimeStamp` production in Clause 21).

  - `type: :time`            -> context tag 0, `time` field present
  - `type: :sequence_number` -> context tag 1, `sequence_number` (0..65535)
  - `type: :datetime`        -> context tag 2, `datetime` field present

  The discriminant `type` tells the encoder which context tag to emit.
  """
  @type t :: %__MODULE__{
          type: :time | :sequence_number | :datetime,
          time: BACnetTime.t() | nil,
          sequence_number: non_neg_integer() | nil,
          datetime: BACnetDateTime.t() | nil
        }

  @fields [
    :type,
    :time,
    :sequence_number,
    :datetime
  ]
  @enforce_keys @fields
  defstruct @fields

  @doc """
  Encodes the given BACnet timestamp into an application tag.
  """
  @spec encode(t(), Keyword.t()) :: {:ok, ApplicationTags.encoding_list()} | {:error, term()}
  def encode(timestamp, opts \\ [])

  def encode(%__MODULE__{type: :time} = timestamp, opts) do
    with {:ok, value, _header} <- ApplicationTags.encode_value({:time, timestamp.time}, opts) do
      {:ok, [{:tagged, {0, value, byte_size(value)}}]}
    end
  end

  def encode(%__MODULE__{type: :sequence_number} = timestamp, opts) do
    with {:ok, value, _header} <-
           ApplicationTags.encode_value({:unsigned_integer, timestamp.sequence_number}, opts) do
      {:ok, [{:tagged, {1, value, byte_size(value)}}]}
    end
  end

  def encode(%__MODULE__{type: :datetime, datetime: %BACnetDateTime{} = dt} = _timestamp, _opts) do
    {:ok, [{:constructed, {2, [date: dt.date, time: dt.time], 0}}]}
  end

  @doc """
  Decodes the given application tags encoding into a BACnet timestamp.

  Example:

      iex> BACnetTimestamp.parse([{:tagged, {0, <<2, 12, 49, 0>>, 4}}])
      {:ok,
      {%BACnetTimestamp{
        datetime: nil,
        sequence_number: nil,
        time: %BACnetTime{
          hour: 2,
          hundredth: 0,
          minute: 12,
          second: 49
        },
        type: :time
      }, []}}
  """
  @spec parse(ApplicationTags.encoding_list()) ::
          {:ok, {t(), rest :: ApplicationTags.encoding_list()}} | {:error, term}
  def parse(tags) when is_list(tags) do
    with [head | rest] <- tags,
         {:ok, result} <- do_parse(head) do
      {:ok, {result, rest}}
    else
      [] -> {:error, :invalid_tags}
      {:error, :invalid_data} -> {:error, :invalid_tags}
      {:error, :unknown_tag_encoding} -> {:error, :invalid_tags}
      {:error, _err} = err -> err
    end
  end

  @spec do_parse(ApplicationTags.encoding()) :: {:ok, t()} | {:error, term}
  defp do_parse({:tagged, {0, _value, 4}} = value) do
    case ApplicationTags.unfold_to_type(:time, value) do
      {:ok, {:time, time}} ->
        result = %__MODULE__{
          type: :time,
          time: time,
          sequence_number: nil,
          datetime: nil
        }

        {:ok, result}

      {:error, _term} = term ->
        term
    end
  end

  defp do_parse({:tagged, {1, _value, _len}} = value) do
    case ApplicationTags.unfold_to_type(:unsigned_integer, value) do
      {:ok, {:unsigned_integer, seqnum}} ->
        result = %__MODULE__{
          type: :sequence_number,
          sequence_number: seqnum,
          datetime: nil,
          time: nil
        }

        {:ok, result}

      {:error, _term} = term ->
        term
    end
  end

  defp do_parse({:constructed, {2, ts, _len}}) when is_list(ts) do
    case BACnetDateTime.parse(ts) do
      {:ok, {dt, _rest}} ->
        result = %__MODULE__{
          type: :datetime,
          datetime: dt,
          sequence_number: nil,
          time: nil
        }

        {:ok, result}

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

  defp do_parse(_arg), do: {:error, :invalid_tags}

  @doc """
  Validates whether the given BACnet timestamp 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__{
          type: :time,
          time: %BACnetTime{} = time
        } = _t
      ) do
    BACnetTime.valid?(time)
  end

  def valid?(
        %__MODULE__{
          type: :sequence_number,
          sequence_number: num
        } = _t
      )
      when is_integer(num) and num >= 0,
      do: true

  def valid?(
        %__MODULE__{
          type: :datetime,
          datetime: %BACnetDateTime{} = dt
        } = _t
      ) do
    BACnetDateTime.valid?(dt)
  end

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