Current section
Files
Jump to
Current section
Files
lib/bacnet/protocol/date_range.ex
defmodule BACnet.Protocol.DateRange do
@moduledoc """
A Date Range defines an inclusive period bounded by a start `BACnet.Protocol.BACnetDate` and
an end `BACnet.Protocol.BACnetDate`. It is one of the three CHOICE alternatives inside
`BACnet.Protocol.CalendarEntry` (alongside a specific `BACnet.Protocol.BACnetDate` and
a `BACnet.Protocol.WeekNDay` pattern).
Date ranges are the natural way to express holiday blocks, maintenance windows,
or temporary schedule overrides that span multiple consecutive days.
### BACnet Specification References
- **ASN.1** (Clause 21): `BACnetDateRange ::= SEQUENCE { startDate Date, endDate Date }`
- **Usage** (Clause 12):
- `date_list` property of Calendar objects (as `BACnet.Protocol.CalendarEntry` CHOICE)
- `exception_schedule` of Schedule objects (as `BACnet.Protocol.SpecialEvent.period`)
- `ReadRange` "by time" requests (start/stop as date+time)
- Both endpoints are inclusive.
This module also provides a small helper to convert a fully-specified range
into an Elixir `Date.Range` for easier processing.
### Examples
```elixir
iex> range = %DateRange{
...> start_date: %BACnetDate{year: 2025, month: 6, day: 1, weekday: 7},
...> end_date: %BACnetDate{year: 2025, month: 6, day: 30, weekday: 1}
...> }
iex> {:ok, elixir_range} = DateRange.get_date_range(range)
iex> elixir_range.first
~D[2025-06-01]
```
### See Also
- `BACnet.Protocol.CalendarEntry`
- `BACnet.Protocol.DaysOfWeek`
- `BACnet.Protocol.ReadRange`
- `BACnet.Protocol.SpecialEvent`
- `BACnet.Protocol.WeekNDay`
"""
# TODO: Throw argument error in encode if not valid
alias BACnet.Protocol.ApplicationTags
alias BACnet.Protocol.BACnetDate
@typedoc """
Inclusive start/end date pair used inside `BACnet.Protocol.CalendarEntry` and `BACnet.Protocol.SpecialEvent`
(and for ReadRange by time). Dates may contain wildcards.
"""
@type t :: %__MODULE__{
start_date: BACnetDate.t(),
end_date: BACnetDate.t()
}
@fields [
:start_date,
:end_date
]
@enforce_keys @fields
defstruct @fields
@doc """
Get a `Date.Range` struct for this date range. Only specific dates are allowed.
"""
@spec get_date_range(t()) :: {:ok, Date.Range.t()} | {:error, term()}
def get_date_range(%__MODULE__{} = date_range) do
if date_specific_enough_for_date_range?(date_range.start_date) and
date_specific_enough_for_date_range?(date_range.end_date) do
with {:ok, start_date} <- BACnetDate.to_date(date_range.start_date),
{:ok, end_date} <- BACnetDate.to_date(date_range.end_date) do
{:ok, Date.range(start_date, end_date)}
end
else
{:error, :invalid_date_range}
end
end
@doc """
Encodes a BACnet date range into BACnet application tags encoding.
"""
@spec encode(t(), Keyword.t()) ::
{:ok, ApplicationTags.encoding_list()} | {:error, term()}
def encode(%__MODULE__{} = date_range, _opts \\ []) do
params = [
{:date, date_range.start_date},
{:date, date_range.end_date}
]
{:ok, params}
end
@doc """
Parses a BACnet date range 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
[
{:date, start_date},
{:date, end_date}
| rest
] ->
range = %__MODULE__{
start_date: start_date,
end_date: end_date
}
{:ok, {range, rest}}
_else ->
{:error, :invalid_tags}
end
end
@doc """
Validates whether the given BACnet date range is in form valid.
It only validates the struct is valid as per type specification.
"""
@spec valid?(t()) :: boolean()
def valid?(
%__MODULE__{
start_date: %BACnetDate{} = start_date,
end_date: %BACnetDate{} = end_date
} = _t
) do
BACnetDate.valid?(start_date) and BACnetDate.valid?(end_date) and
valid_date_range?(start_date, end_date)
end
def valid?(%__MODULE__{} = _t), do: false
@spec valid_date_range?(BACnetDate.t(), BACnetDate.t()) :: boolean()
defp valid_date_range?(%BACnetDate{} = start_date, %BACnetDate{} = end_date) do
{any1, all1} = date_any_unspecified?(start_date)
{any2, all2} = date_any_unspecified?(end_date)
# ASHRAE 135-2012: A date may be unspecific or a specific date only
(not any1 and not any2 and BACnetDate.compare(start_date, end_date) != :gt) or
(all1 and all2) or
(all1 and not any2) or
(all2 and not any1)
end
@date_keys Map.keys(Map.from_struct(BACnetDate.__struct__()))
defp date_any_unspecified?(%BACnetDate{
unquote_splicing(Enum.map(@date_keys, &{&1, :unspecified}))
}),
do: {true, true}
for key <- @date_keys do
defp date_any_unspecified?(%BACnetDate{unquote(key) => :unspecified}), do: {true, false}
end
defp date_any_unspecified?(%BACnetDate{month: :odd}), do: {true, false}
defp date_any_unspecified?(%BACnetDate{month: :even}), do: {true, false}
defp date_any_unspecified?(%BACnetDate{day: :odd}), do: {true, false}
defp date_any_unspecified?(%BACnetDate{day: :even}), do: {true, false}
defp date_any_unspecified?(%BACnetDate{} = _date), do: {false, false}
defp date_specific_enough_for_date_range?(%BACnetDate{
year: year,
month: mon,
day: day,
weekday: _any
})
when is_integer(year) and is_integer(mon) and (is_integer(day) or day == :last),
do: true
defp date_specific_enough_for_date_range?(%BACnetDate{} = _date), do: false
end