From 363d40eb686077eb8e703332ec05929c4b7f1e33 Mon Sep 17 00:00:00 2001 From: Andrew Grimberg Date: Fri, 18 Mar 2022 10:02:19 -0700 Subject: [PATCH 1/3] Docs: Document configurable calendar refresh rates Issue: #79 Signed-off-by: Andrew Grimberg --- README.md | 8 +++++++- info.md | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 090b80e..00109b8 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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\_\\_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 diff --git a/info.md b/info.md index 8838534..48b2670 100644 --- a/info.md +++ b/info.md @@ -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 @@ -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 @@ -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\_\\_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 From 965b9a6d0c9a1d9aee9ea40b4f1485130f7028d4 Mon Sep 17 00:00:00 2001 From: Andrew Grimberg Date: Fri, 18 Mar 2022 08:17:18 -0700 Subject: [PATCH 2/3] Refactor: Separate calendar refresh from update Separate the calendar refresh logic from the update function. This will allow for the calendar to be refreshed via different calls. Issue: #79 Signed-off-by: Andrew Grimberg --- custom_components/rental_control/__init__.py | 68 +++++++++++--------- 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/custom_components/rental_control/__init__.py b/custom_components/rental_control/__init__.py index f3875d8..aefb1f9 100644 --- a/custom_components/rental_control/__init__.py +++ b/custom_components/rental_control/__init__.py @@ -173,39 +173,10 @@ async def async_get_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 + await self._refresh_calendar() def update_config(self, config): """Update config entries.""" @@ -349,3 +320,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 From 0eb557cc24e05c307ae3ff48db0667baab4dbf31 Mon Sep 17 00:00:00 2001 From: Andrew Grimberg Date: Fri, 18 Mar 2022 09:55:00 -0700 Subject: [PATCH 3/3] Feat: Configurable calendar refresh frequency Some calendars have a limited number of times per day that the calendar can be refreshed. Since the refresh was originally hard coded at no more than once every 2 minutes this caused polling exhaustion for some users. The configuration now supports a polling frequency between as often as possible (roughly every 30 seconds because of the HA event machine) up to once per day. This configuration is given as a number of minutes between 0 and 1440. Issue: #79 Signed-off-by: Andrew Grimberg --- custom_components/rental_control/__init__.py | 42 +++++++++++++------ .../rental_control/config_flow.py | 10 +++++ custom_components/rental_control/const.py | 2 + custom_components/rental_control/strings.json | 4 ++ .../rental_control/translations/en.json | 4 ++ 5 files changed, 50 insertions(+), 12 deletions(-) diff --git a/custom_components/rental_control/__init__.py b/custom_components/rental_control/__init__.py index aefb1f9..92628b8 100644 --- a/custom_components/rental_control/__init__.py +++ b/custom_components/rental_control/__init__.py @@ -28,7 +28,6 @@ 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 @@ -36,7 +35,9 @@ 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 @@ -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 @@ -171,24 +171,42 @@ async def async_get_events( events.append(event) return events - @Throttle(MIN_TIME_BETWEEN_UPDATES) async def update(self): """Regularly update the calendar.""" _LOGGER.debug("Running ICalEvents update for calendar %s", self.name) - await self._refresh_calendar() + 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 diff --git a/custom_components/rental_control/config_flow.py b/custom_components/rental_control/config_flow.py index 612ba96..05a2291 100644 --- a/custom_components/rental_control/config_flow.py +++ b/custom_components/rental_control/config_flow.py @@ -28,6 +28,7 @@ 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 @@ -35,6 +36,7 @@ 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 @@ -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, } @@ -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)), @@ -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"]) diff --git a/custom_components/rental_control/const.py b/custom_components/rental_control/const.py index 04dba1c..e86091e 100644 --- a/custom_components/rental_control/const.py +++ b/custom_components/rental_control/const.py @@ -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" @@ -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""" diff --git a/custom_components/rental_control/strings.json b/custom_components/rental_control/strings.json index f707231..f7fe207 100644 --- a/custom_components/rental_control/strings.json +++ b/custom_components/rental_control/strings.json @@ -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", @@ -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", @@ -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" @@ -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", diff --git a/custom_components/rental_control/translations/en.json b/custom_components/rental_control/translations/en.json index f707231..bae6014 100644 --- a/custom_components/rental_control/translations/en.json +++ b/custom_components/rental_control/translations/en.json @@ -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" @@ -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", @@ -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" @@ -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",