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

lib/bacnet/protocol/bacnet_time.ex

defmodule BACnet.Protocol.BACnetTime do
  @moduledoc """
  A BACnet Time is a structured value (application tag 11) that can represent either
  a specific time of day or a *time pattern* with one or more components marked as
  `:unspecified`. It is the natural companion to `BACnet.Protocol.BACnetDate` and
  appears in the same contexts: schedules, calendars, trend logs, event notifications,
  and TimeSynchronization services.

  Each component (hour, minute, second, hundredth) may be a concrete integer or
  the atom `:unspecified` (encoded as 0xFF). A completely unspecified time is
  treated as "don't care".

  ### BACnet Specification References

  - **Encoding**: ASHRAE 135-2012 Clause 20.2.13. Four contents octets: hour
    (0-23), minute, second, hundredths. 0xFF in any position means unspecified.
  - **Restrictions** (20.2.13): Neither an unspecified time nor a time pattern
    shall be used when conveying an actual time (e.g. the Device object's
    `Local_Time` property or a TimeSynchronization-Request).
  - **ASN.1** (Clause 21): `Time ::= [APPLICATION 11] OCTET STRING (SIZE(4))`.
  - **Primary usage**: `weekly_schedule` / `exception_schedule` entries (via
    `TimeValue`), ReadRange time ranges, Event Timestamps, and many "last changed"
    time properties.

  The helpers in this module correctly round-trip with Elixir `Time` while
  treating `:unspecified` components by substituting values from a reference time.

  ### Examples

  #### Specific time

  ```elixir
  iex> time = %BACnetTime{hour: 8, minute: 30, second: 0, hundredth: 0}
  iex> BACnetTime.specific?(time)
  true
  iex> BACnetTime.to_time!(time)
  ~T[08:30:00]
  ```

  #### Partial wildcard (e.g. "every day at 9:15")

  ```elixir
  iex> daily = %BACnetTime{hour: 9, minute: 15, second: :unspecified, hundredth: :unspecified}
  iex> BACnetTime.specific?(daily)
  false
  ```

  #### Edge cases

  All components unspecified resolves using the reference time:

  ```elixir
  iex> any_time = %BACnetTime{hour: :unspecified, minute: :unspecified, second: :unspecified, hundredth: :unspecified}
  iex> BACnetTime.specific?(any_time)
  false
  iex> BACnetTime.to_time!(any_time, ~T[14:30:00])
  ~T[14:30:00]
  ```

  > #### Warning {: .warning}
  >
  > Per the specification, unspecified times or time patterns **shall not** be used
  > for actual time values such as the Device object's `Local_Time`.

  ### See Also
  - `BACnet.Protocol.BACnetDate`
  - `BACnet.Protocol.BACnetDateTime`
  - `BACnet.Protocol.BACnetTimestamp`
  - `BACnet.Protocol.TimeValue`
  """

  # TODO: Throw argument error in encode if not valid

  alias BACnet.Protocol.ApplicationTags

  @typedoc """
  Represents a BACnet Time (application tag 11).

  - `hour`      - 0-23 or `:unspecified`
  - `minute`    - 0-59 or `:unspecified`
  - `second`    - 0-59 or `:unspecified`
  - `hundredth` - 0-99 (hundredths of a second) or `:unspecified`

  Any component may be `:unspecified`, turning the value into a time pattern.
  `specific?/1` returns false for any such pattern. See `to_time/2` for how
  patterns are resolved against a reference time.
  """
  @type t :: %__MODULE__{
          hour: 0..23 | :unspecified,
          minute: 0..59 | :unspecified,
          second: 0..59 | :unspecified,
          hundredth: 0..99 | :unspecified
        }

  @fields [
    :hour,
    :minute,
    :second,
    :hundredth
  ]
  @enforce_keys @fields
  defstruct @fields

  @doc """
  Compares two BACnet Time.

  Returns `:gt` if first time is later than the second,
  and `:lt` for vice versa.
  If the two times are equal, `:eq` is returned.

  Note that this is achieved by converting to `Time` and then
  comparing them.
  """
  @spec compare(t(), t()) :: :gt | :eq | :lt
  def compare(%__MODULE__{} = time1, %__MODULE__{} = time2) do
    Time.compare(to_time!(time1), to_time!(time2))
  end

  @doc """
  Encodes the given BACnet Time into an application tag.
  """
  @spec encode(t(), Keyword.t()) :: {:ok, ApplicationTags.encoding_list()} | {:error, term()}
  def encode(%__MODULE__{} = time, _opts \\ []) do
    {:ok, [{:time, time}]}
  end

  @doc """
  Converts a `Time` into a BACnet Time.
  """
  @spec from_time(Time.t()) :: t()
  def from_time(%Time{} = time) do
    %__MODULE__{
      hour: time.hour,
      minute: time.minute,
      second: time.second,
      hundredth: patch_microsecond(time.microsecond)
    }
  end

  @doc """
  Parses a BACnet Time 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
      [{:time, %__MODULE__{} = time} | rest] ->
        {:ok, {time, rest}}

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

  @doc """
  Checks whether the given BACnet Time is a specific time value
  (every component is a numeric value).
  """
  @spec specific?(t()) :: boolean()
  def specific?(%__MODULE__{} = time) do
    case time do
      %{hour: :unspecified} -> false
      %{minute: :unspecified} -> false
      %{second: :unspecified} -> false
      %{hundredth: :unspecified} -> false
      _else -> true
    end
  end

  @doc """
  Converts a BACnet Time into a `Time`.

  If any of the fields are unspecified, the reference time (current UTC value) is used.
  """
  @spec to_time(t(), Time.t()) :: {:ok, Time.t()} | {:error, term()}
  def to_time(%__MODULE__{} = time, ref_time \\ Time.utc_now()) do
    hundredth = get_component(:hundredth, time, ref_time)

    Time.new(
      get_component(:hour, time, ref_time),
      get_component(:minute, time, ref_time),
      get_component(:second, time, ref_time),
      {hundredth, if(hundredth > 0, do: 6, else: 0)}
    )
  end

  @doc """
  Bang-version of `to_time/1`.
  """
  @spec to_time!(t(), Time.t()) :: Time.t() | no_return()
  def to_time!(%__MODULE__{} = time, ref_time \\ Time.utc_now()) do
    hundredth = get_component(:hundredth, time, ref_time)

    Time.new!(
      get_component(:hour, time, ref_time),
      get_component(:minute, time, ref_time),
      get_component(:second, time, ref_time),
      {hundredth, if(hundredth > 0, do: 6, else: 0)}
    )
  end

  @doc """
  Creates a new BACnet Time with the current UTC time.
  """
  @spec utc_now() :: t()
  def utc_now() do
    from_time(Time.utc_now())
  end

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

  It only validates the struct is valid as per type specification.
  """
  @spec valid?(t()) :: boolean()
  def valid?(
        %__MODULE__{
          hour: hour,
          minute: minute,
          second: second,
          hundredth: hundredth
        } = _t
      )
      when (hour in 0..23 or hour == :unspecified) and (minute in 0..59 or minute == :unspecified) and
             (second in 0..59 or second == :unspecified) and
             (hundredth in 0..99 or hundredth == :unspecified),
      do: true

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

  defp get_component(:hundredth, %__MODULE__{hundredth: :unspecified} = _time, %Time{} = utc_now) do
    patch_microsecond(utc_now.microsecond)
  end

  defp get_component(:hundredth, %__MODULE__{hundredth: hundredth} = _time, _utc_now),
    do: hundredth * 10_000

  defp get_component(component, %__MODULE__{} = time, utc_now)
       when :erlang.map_get(component, time) == :unspecified,
       do: Map.fetch!(utc_now, component)

  defp get_component(component, %__MODULE__{} = time, _utc_now), do: Map.fetch!(time, component)

  @spec patch_microsecond(Calendar.microsecond()) :: non_neg_integer()
  defp patch_microsecond(microsecond_prec) do
    case microsecond_prec do
      {_value, 0} -> 0
      {value, 1} -> value * 10
      {value, 2} -> value
      {value, 3} -> Integer.floor_div(value, 10)
      {value, 4} -> Integer.floor_div(value, 100)
      {value, 5} -> Integer.floor_div(value, 1000)
      {value, 6} -> Integer.floor_div(value, 10_000)
    end
  end
end