Skip to content

Commit

Permalink
Merge pull request #82 from tykeal/tunable_frequency
Browse files Browse the repository at this point in the history
Feat: Tunable calendar refresh frequency
  • Loading branch information
tykeal authored Mar 18, 2022
2 parents bef0fea + 0eb557c commit b43c679
Show file tree
Hide file tree
Showing 7 changed files with 100 additions and 44 deletions.
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ calendars and sensors to go with them related to managing rental properties.

- Ingests ICS calendars from any HTTPS source as long as it's a text/calendar
file
- Configurable refresh rate from as often as possible to once per day (default
every 2 minutes)
- Define checkin/checkout times which will be added to all calendar entries
- Ability to ignore 'Blocked' and 'Not available' events
- Creates a customizable number of event sensors that are the current and
Expand All @@ -22,9 +24,10 @@ calendars and sensors to go with them related to managing rental properties.
- Calendars can have their own timezone definition that is separate from the
Home Assitant instance itself. This is useful for managing properties that are
in a different timezone from where Home Assistant is

- Events can have a custom prefix added to them to help differentiate between
entities if more than one calendar is being tracked in an instance
- Forcing a calendar refresh is currently possible by submitting a
configuration change

## Planned features

Expand Down Expand Up @@ -70,6 +73,9 @@ The integration is set up using the GUI.
- By default it will set up 5 sensors for the 5 nex upcoming events
(sensor.rental_control\_\<calendar_name\>\_event_0 ~ 4). You can adjust this
to add more or fewer sensors
- The calendar refresh rate defaults to every 2 minutes but can be set to 0
for as often as possible (roughly every 30 seconds) to once per day (1440).
This is adjustable in minute increments
- The integration will only consider events with a start time 365 days (1 year)
into the future by default. This can also be adjusted when adding a new
calendar
Expand Down
108 changes: 66 additions & 42 deletions custom_components/rental_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,16 @@
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import dt
from homeassistant.util import Throttle

from .const import CONF_CHECKIN
from .const import CONF_CHECKOUT
from .const import CONF_DAYS
from .const import CONF_EVENT_PREFIX
from .const import CONF_IGNORE_NON_RESERVED
from .const import CONF_MAX_EVENTS
from .const import CONF_REFRESH_FREQUENCY
from .const import CONF_TIMEZONE
from .const import DEFAULT_REFRESH_FREQUENCY
from .const import DOMAIN
from .const import PLATFORMS
from .const import REQUEST_TIMEOUT
Expand Down Expand Up @@ -124,26 +125,25 @@ def __init__(self, hass, config):
self.name = config.get(CONF_NAME)
self.event_prefix = config.get(CONF_EVENT_PREFIX)
self.url = config.get(CONF_URL)
# Early versions did not have this variable, as such it may not be
# Early versions did not have these variables, as such it may not be
# set, this should guard against issues until we're certain we can
# remove this guard.
try:
self.timezone = ZoneInfo(config.get(CONF_TIMEZONE))
except TypeError:
self.timezone = dt.DEFAULT_TIME_ZONE
self.refresh_frequency = config.get(CONF_REFRESH_FREQUENCY)
if self.refresh_frequency is None:
self.refresh_frequency = DEFAULT_REFRESH_FREQUENCY
# after initial setup our first refresh should happen ASAP
self.next_refresh = dt.now()
# our config flow guarantees that checkin and checkout are valid times
# just use cv.time to get the parsed time object
self.checkin = cv.time(config.get(CONF_CHECKIN))
self.checkout = cv.time(config.get(CONF_CHECKOUT))
self.max_events = config.get(CONF_MAX_EVENTS)
self.days = config.get(CONF_DAYS)
# Early versions did not have this variable, as such it may not be
# set, this should guard against issues until we're certain
# we can remove this guard.
try:
self.ignore_non_reserved = config.get(CONF_IGNORE_NON_RESERVED)
except NameError:
self.ignore_non_reserved = None
self.ignore_non_reserved = config.get(CONF_IGNORE_NON_RESERVED)
self.verify_ssl = config.get(CONF_VERIFY_SSL)
self.calendar = []
self.event = None
Expand Down Expand Up @@ -171,53 +171,42 @@ async def async_get_events(
events.append(event)
return events

@Throttle(MIN_TIME_BETWEEN_UPDATES)
async def update(self):
"""Update list of upcoming events."""
"""Regularly update the calendar."""
_LOGGER.debug("Running ICalEvents update for calendar %s", self.name)

session = async_get_clientsession(self.hass, verify_ssl=self.verify_ssl)
with async_timeout.timeout(REQUEST_TIMEOUT):
response = await session.get(self.url)
if response.status != 200:
_LOGGER.error(
"%s returned %s - %s", self.url, response.status, response.reason
)
else:
text = await response.text()
# Some calendars are for some reason filled with NULL-bytes.
# They break the parsing, so we get rid of them
event_list = icalendar.Calendar.from_ical(text.replace("\x00", ""))
start_of_events = dt.start_of_local_day()
end_of_events = dt.start_of_local_day() + timedelta(days=self.days)

self.calendar = self._ical_parser(
event_list, start_of_events, end_of_events
)

if len(self.calendar) > 0:
found_next_event = False
for event in self.calendar:
if event["end"] > dt.now() and not found_next_event:
_LOGGER.debug(
"Event %s it the first event with end in the future: %s",
event["summary"],
event["end"],
)
self.event = event
found_next_event = True
now = dt.now()
_LOGGER.debug("Refresh frequency is: %d", self.refresh_frequency)
_LOGGER.debug("Current time is: %s", now)
_LOGGER.debug("Next refresh is: %s", self.next_refresh)
if now >= self.next_refresh:
# Update the next refresh time before doing the calendar update
# If refresh_frequency is 0, then set the refresh for a little in
# the future to avoid having multiple calls to the calendar refresh
# happen at the same time
if self.refresh_frequency == 0:
self.next_refresh = now + timedelta(seconds=10)
else:
self.next_refresh = now + timedelta(minutes=self.refresh_frequency)
_LOGGER.debug("Updating next refresh to %s", self.next_refresh)
await self._refresh_calendar()

def update_config(self, config):
"""Update config entries."""
self.name = config.get(CONF_NAME)
self.url = config.get(CONF_URL)
# Early versions did not have this variable, as such it may not be
# Early versions did not have these variables, as such it may not be
# set, this should guard against issues until we're certain
# we can remove this guard.
try:
self.timezone = ZoneInfo(config.get(CONF_TIMEZONE))
except TypeError:
self.timezone = dt.DEFAULT_TIME_ZONE
self.refresh_frequency = config.get(CONF_REFRESH_FREQUENCY)
if self.refresh_frequency is None:
self.refresh_frequency = DEFAULT_REFRESH_FREQUENCY
# always do a refresh ASAP after a config change
self.next_refresh = dt.now()
self.event_prefix = config.get(CONF_EVENT_PREFIX)
# our config flow guarantees that checkin and checkout are valid times
# just use cv.time to get the parsed time object
Expand Down Expand Up @@ -349,3 +338,38 @@ def _refresh_event_dict(self):
days = dt.start_of_local_day() + timedelta(days=self.days)

return [x for x in cal if x["start"].date() <= days.date()]

async def _refresh_calendar(self):
"""Update list of upcoming events."""
_LOGGER.debug("Running ICalEvents _refresh_calendar for %s", self.name)

session = async_get_clientsession(self.hass, verify_ssl=self.verify_ssl)
with async_timeout.timeout(REQUEST_TIMEOUT):
response = await session.get(self.url)
if response.status != 200:
_LOGGER.error(
"%s returned %s - %s", self.url, response.status, response.reason
)
else:
text = await response.text()
# Some calendars are for some reason filled with NULL-bytes.
# They break the parsing, so we get rid of them
event_list = icalendar.Calendar.from_ical(text.replace("\x00", ""))
start_of_events = dt.start_of_local_day()
end_of_events = dt.start_of_local_day() + timedelta(days=self.days)

self.calendar = self._ical_parser(
event_list, start_of_events, end_of_events
)

if len(self.calendar) > 0:
found_next_event = False
for event in self.calendar:
if event["end"] > dt.now() and not found_next_event:
_LOGGER.debug(
"Event %s is the first event with end in the future: %s",
event["summary"],
event["end"],
)
self.event = event
found_next_event = True
10 changes: 10 additions & 0 deletions custom_components/rental_control/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@
from .const import CONF_IGNORE_NON_RESERVED
from .const import CONF_LOCK_ENTRY
from .const import CONF_MAX_EVENTS
from .const import CONF_REFRESH_FREQUENCY
from .const import CONF_START_SLOT
from .const import CONF_TIMEZONE
from .const import DEFAULT_CHECKIN
from .const import DEFAULT_CHECKOUT
from .const import DEFAULT_DAYS
from .const import DEFAULT_EVENT_PREFIX
from .const import DEFAULT_MAX_EVENTS
from .const import DEFAULT_REFRESH_FREQUENCY
from .const import DEFAULT_START_SLOT
from .const import DOMAIN
from .const import LOCK_MANAGER
Expand All @@ -59,6 +61,7 @@ class RentalControlFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
CONF_IGNORE_NON_RESERVED: True,
CONF_EVENT_PREFIX: DEFAULT_EVENT_PREFIX,
CONF_MAX_EVENTS: DEFAULT_MAX_EVENTS,
CONF_REFRESH_FREQUENCY: DEFAULT_REFRESH_FREQUENCY,
CONF_TIMEZONE: str(dt.DEFAULT_TIME_ZONE),
CONF_VERIFY_SSL: True,
}
Expand Down Expand Up @@ -161,6 +164,10 @@ def _get_default(key: str, fallback_default: Any = None) -> None:
{
vol.Required(CONF_NAME, default=_get_default(CONF_NAME)): cv.string,
vol.Required(CONF_URL, default=_get_default(CONF_URL)): cv.string,
vol.Optional(
CONF_REFRESH_FREQUENCY,
default=_get_default(CONF_REFRESH_FREQUENCY, DEFAULT_REFRESH_FREQUENCY),
): cv.positive_int,
vol.Optional(
CONF_TIMEZONE,
default=_get_default(CONF_TIMEZONE, str(dt.DEFAULT_TIME_ZONE)),
Expand Down Expand Up @@ -267,6 +274,9 @@ async def _start_config_flow(
_LOGGER.exception(err.msg)
errors["base"] = "invalid_url"

if user_input[CONF_REFRESH_FREQUENCY] > 1440:
errors["base"] = "bad_refresh"

try:
cv.time(user_input["checkin"])
cv.time(user_input["checkout"])
Expand Down
2 changes: 2 additions & 0 deletions custom_components/rental_control/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
CONF_IGNORE_NON_RESERVED = "ignore_non_reserved"
CONF_LOCK_ENTRY = "keymaster_entry_id"
CONF_MAX_EVENTS = "max_events"
CONF_REFRESH_FREQUENCY = "refresh_frequency"
CONF_START_SLOT = "start_slot"
CONF_TIMEZONE = "timezone"

Expand All @@ -37,6 +38,7 @@
DEFAULT_EVENT_PREFIX = ""
DEFAULT_MAX_EVENTS = 5
DEFAULT_NAME = DOMAIN
DEFAULT_REFRESH_FREQUENCY = 2
DEFAULT_START_SLOT = 10

STARTUP_MESSAGE = f"""
Expand Down
4 changes: 4 additions & 0 deletions custom_components/rental_control/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"config": {
"error": {
"bad_ics": "URL did not provide valid calendar",
"bad_refresh": "Refresh must be between 0 and 1440.",
"bad_time": "Check-in/out time is invalid. Use 24 hour time",
"invalid_url": "Only https URLs are supported",
"same_name": "Name already in use",
Expand All @@ -18,6 +19,7 @@
"keymaster_entry_id": "Keymaster configuration to manage",
"max_events": "Number of event sensors to create",
"name": "Calendar name",
"refresh_frequency": "Refresh calendar in minutes. 0 - 1440",
"start_slot": "Starting lock slot for management",
"timezone": "Calendar Timezone",
"url": "Calendar URL",
Expand All @@ -30,6 +32,7 @@
"error": {
"bad_ics": "URL did not provide valid calendar",
"bad_time": "Check-in/out time is invalid. Use 24 hour time",
"bad_refresh": "Refresh must be between 0 and 1440.",
"invalid_url": "Only https URLs are supported",
"same_name": "Name already in use",
"unknown": "Unexpected error, check logs for more details"
Expand All @@ -45,6 +48,7 @@
"keymaster_entry_id": "Keymaster configuration to manage",
"max_events": "Number of event sensors to create",
"name": "Calendar name",
"refresh_frequency": "Refresh calendar in minutes. 0 - 1440",
"start_slot": "Starting lock slot for management",
"timezone": "Calendar Timezone",
"url": "Calendar URL",
Expand Down
4 changes: 4 additions & 0 deletions custom_components/rental_control/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"error": {
"bad_ics": "URL did not provide valid calendar",
"bad_time": "Check-in/out time is invalid. Use 24 hour time",
"bad_refresh": "Refresh must be between 0 and 1440.",
"invalid_url": "Only https URLs are supported",
"same_name": "Name already in use",
"unknown": "Unexpected error, check logs for more details"
Expand All @@ -18,6 +19,7 @@
"keymaster_entry_id": "Keymaster configuration to manage",
"max_events": "Number of event sensors to create",
"name": "Calendar name",
"refresh_frequency": "Refresh calendar in minutes. 0 - 1440",
"start_slot": "Starting lock slot for management",
"timezone": "Calendar Timezone",
"url": "Calendar URL",
Expand All @@ -30,6 +32,7 @@
"error": {
"bad_ics": "URL did not provide valid calendar",
"bad_time": "Check-in/out time is invalid. Use 24 hour time",
"bad_refresh": "Refresh must be between 0 and 1440.",
"invalid_url": "Only https URLs are supported",
"same_name": "Name already in use",
"unknown": "Unexpected error, check logs for more details"
Expand All @@ -45,6 +48,7 @@
"keymaster_entry_id": "Keymaster configuration to manage",
"max_events": "Number of event sensors to create",
"name": "Calendar name",
"refresh_frequency": "Refresh calendar in minutes. 0 - 1440",
"start_slot": "Starting lock slot for management",
"timezone": "Calendar Timezone",
"url": "Calendar URL",
Expand Down
8 changes: 7 additions & 1 deletion info.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ calendars and sensors to go with them related to managing rental properties.

- Ingests ICS calendars from any HTTPS source as long as it's a text/calendar
file
- Configurable refresh rate from as often as possible to once per day (default
every 2 minutes)
- Define checkin/checkout times which will be added to all calendar entries
- Ability to ignore 'Blocked' and 'Not available' events
- Creates a customizable number of event sensors that are the current and
Expand All @@ -26,9 +28,10 @@ calendars and sensors to go with them related to managing rental properties.
- Calendars can have their own timezone definition that is separate from the
Home Assitant instance itself. This is useful for managing properties that are
in a different timezone from where Home Assistant is

- Events can have a custom prefix added to them to help differentiate between
entities if more than one calendar is being tracked in an instance
- Forcing a calendar refresh is currently possible by submitting a
configuration change

## Planned features

Expand All @@ -45,6 +48,9 @@ The integration is set up using the GUI.
- By default it will set up 5 sensors for the 5 nex upcoming events
(sensor.rental_control\_\<calendar_name\>\_event_0 ~ 4). You can adjust this
to add more or fewer sensors
- The calendar refresh rate defaults to every 2 minutes but can be set to 0
for as often as possible (roughly every 30 seconds) to once per day (1440).
This is adjustable in minute increments
- The integration will only consider events with a start time 365 days (1 year)
into the future by default. This can also be adjusted when adding a new
calendar
Expand Down

0 comments on commit b43c679

Please sign in to comment.