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

lib/bacnet/protocol/calendar_entry.ex

defmodule BACnet.Protocol.CalendarEntry do
  @moduledoc """
  A Calendar Entry is the CHOICE type used by both Calendar objects and Schedule
  objects to describe "special" days (holidays, maintenance periods, after-hours
  operation, etc.).

  The three alternatives (defined in Clause 21) are:
  - `:date`       - a single specific (or partially unspecified) `BACnet.Protocol.BACnetDate`
  - `:date_range` - an inclusive `BACnet.Protocol.DateRange`
  - `:week_n_day` - a recurring `BACnet.Protocol.WeekNDay` pattern

  This single type is deliberately reused in multiple places so that matching
  logic ("does today match any exception?") can be written once.

  ### BACnet Specification References
  - **ASN.1** (Clause 21):
    ```
    BACnetCalendarEntry ::= CHOICE {
        date       Date,
        dateRange  BACnetDateRange,
        weekNDay   BACnetWeekNDay
    }
    ```
  - **Calendar object** (12.9): `date_list` property is `BACnetARRAY` of `BACnet.Protocol.CalendarEntry`.
  - **Schedule object** (12.24): `exception_schedule` contains `BACnet.Protocol.SpecialEvent`
    whose `period` can be a `BACnet.Protocol.CalendarEntry` or an object identifier referencing
    a Calendar object.

  ### Examples

  #### Specific date entry

  ```elixir
  iex> entry = %CalendarEntry{type: :date, date: %BACnetDate{year: 2025, month: 12, day: 25, weekday: 4}, date_range: nil, week_n_day: nil}
  iex> entry.type
  :date
  ```

  #### Date range entry

  ```elixir
  iex> entry = %CalendarEntry{type: :date_range, date_range: %DateRange{start_date: %BACnetDate{year: 2025, month: 7, day: 1, weekday: 2}, end_date: %BACnetDate{year: 2025, month: 7, day: 31, weekday: 4}}, date: nil, week_n_day: nil}
  iex> entry.type
  :date_range
  ```

  #### Recurring WeekNDay entry

  ```elixir
  iex> entry = %CalendarEntry{type: :week_n_day, week_n_day: %WeekNDay{month: :unspecified, week_of_month: 3, weekday: 2}, date: nil, date_range: nil}
  iex> entry.type
  :week_n_day
  ```

  ### See Also
  - `BACnet.Protocol.DailySchedule`
  - `BACnet.Protocol.DateRange`
  - `BACnet.Protocol.DaysOfWeek`
  - `BACnet.Protocol.SpecialEvent`
  - `BACnet.Protocol.WeekNDay`
  """

  # TODO: Throw argument error in encode if not valid

  alias BACnet.Protocol.ApplicationTags
  alias BACnet.Protocol.BACnetDate
  alias BACnet.Protocol.DateRange
  alias BACnet.Protocol.WeekNDay

  @typedoc """
  CHOICE of a single date, date range, or `BACnet.Protocol.WeekNDay` pattern
  (see `BACnet.Protocol.CalendarEntry` module docs + ASN.1 in Clause 21).
  Used in Calendar `date_list` and Schedule exception schedules.
  """
  @type t :: %__MODULE__{
          type: :date | :date_range | :week_n_day,
          date: BACnetDate.t() | nil,
          date_range: DateRange.t() | nil,
          week_n_day: WeekNDay.t() | nil
        }

  @fields [:type, :date, :date_range, :week_n_day]
  @enforce_keys @fields
  defstruct @fields

  @doc """
  Encode a calendar entry into application tag-encoded.
  """
  @spec encode(t(), Keyword.t()) ::
          {:ok, ApplicationTags.encoding_list()} | {:error, term()}
  def encode(entry, opts \\ [])

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

  def encode(
        %__MODULE__{
          type: :date_range,
          date_range: %DateRange{
            start_date: %BACnetDate{} = start_date,
            end_date: %BACnetDate{} = end_date
          }
        } = _entry,
        _opts
      ) do
    {:ok,
     [
       {:constructed,
        {1,
         [
           date: start_date,
           date: end_date
         ], 0}}
     ]}
  end

  def encode(%__MODULE__{type: :week_n_day, week_n_day: %WeekNDay{} = week_n_day} = _entry, _opts) do
    with {:ok, [{:octet_string, str}]} <- WeekNDay.encode(week_n_day) do
      {:ok, [{:tagged, {2, str, 3}}]}
    end
  end

  @doc """
  Parse application tag-encoded calendar entry into a struct.
  """
  @spec parse(ApplicationTags.encoding_list()) ::
          {:ok, {t(), rest :: ApplicationTags.encoding_list()}}
          | {:error, term()}
  def parse(tags) when is_list(tags) do
    case tags do
      [{:tagged, {0, tags, _len}} | rest] ->
        with {:ok, {:date, date}} <- ApplicationTags.unfold_to_type(:date, tags) do
          cal = %__MODULE__{
            type: :date,
            date: date,
            date_range: nil,
            week_n_day: nil
          }

          {:ok, {cal, rest}}
        end

      [
        {:constructed,
         {1,
          [
            {:date, start_date},
            {:date, end_date}
          ], _len}}
        | rest
      ] ->
        cal = %__MODULE__{
          type: :date_range,
          date: nil,
          date_range: %DateRange{
            start_date: start_date,
            end_date: end_date
          },
          week_n_day: nil
        }

        {:ok, {cal, rest}}

      [{:tagged, {2, str, _len}} | rest] ->
        with {:ok, {week, _rest}} <- WeekNDay.parse([{:octet_string, str}]) do
          cal = %__MODULE__{
            type: :week_n_day,
            date: nil,
            date_range: nil,
            week_n_day: week
          }

          {:ok, {cal, rest}}
        end

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

  @doc """
  Validates whether the given BACnet calendar entry 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: :date,
          date: %BACnetDate{} = date
        } = _t
      ) do
    BACnetDate.valid?(date)
  end

  def valid?(
        %__MODULE__{
          type: :date_range,
          date_range: %DateRange{} = range
        } = _t
      ) do
    DateRange.valid?(range)
  end

  def valid?(
        %__MODULE__{
          type: :week_n_day,
          week_n_day: %WeekNDay{} = week
        } = _t
      ) do
    WeekNDay.valid?(week)
  end

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