diff --git a/README.md b/README.md index 717ad6a..d1ff927 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,51 @@ calendars and sensors to go with them related to managing rental properties. - Reservation url -- the URL to the reservation - Integration with [Keymaster](https://github.com/FutureTense/keymaster) to control door codes matched to the number of events being tracked +- Custom calendars are supported as long as they provide a valid ICS file via + an HTTPS connection. + - Events on the calendar can be done in multiple ways, but all events will + be treated as all day events (which is how all of the rental platforms + provide events). + - The event Summary (aka event title) _may_ contiain the word Reserved. + This will cause the slot name to be generated in one of two ways: + - The word Reserved is followed by ' - ' and then something else, the + something else will be used + - The word Reserved is _not_ followed by ' - ' then the full slot will + be used + - The Summary contains nothing else _and_ the Details contain + something that matches an Airbnb reservation identifier of + `[A-Z][A-Z0-9]{9}` that is a capital alphabet letter followed by 9 + more characters that are either capital alphabet letters or numbers, + then the slot will get this + - If the the Summary is _just_ Reserved and there is no Airbnb code in + the Description, then the event will be ignored for purposes of + managing a lock code. + - Technically any of the othe supported platform event styles for the + Summary can be used and as long as the Summary conforms to it. + - The best Summary on a manual calendar is to use your guest name. The + entries do need to be unique over the sensor count worth of events + or Rental Control will run into issues. + - Additional information can be provided in the Description of the event + and it will fill in the extra details in the sensor. + - Phone numbers for use in generating door codes can be provided in + one of two ways + - A line in the Description matching this regular expression: + `\(?Last 4 Digits\)?:\s+(\d{4})` -- This line will always take + precedence for generating a door code based on last 4 digits. + - A line in the Description matching this regular expression: + `Phone(?: Number)?:\s+(\+?[\d\. \-\(\)]{9,})` which will then + have the "air" squeezed out of it to extract the last 4 digits + in the number + - Number of guests + - A line in the Description that matches: `Guests:\s+(\d+)$` + - Alternatively, the following lines will be added together to get + the data: + - `Adults:\s+(\d+)$` + - `Children:\s+(\d+)$` + - Email addresses can be extracted from the Description by matching + against: `Email:\s+(\S+@\S+)` + - Reservation URLS will match against the first (and hopefully only) + URL in the Description ## Installation @@ -108,21 +153,11 @@ The integration is set up using the GUI. slot set correctly for the integration. - It is _very_ important that you have Keymaster fully working before - trying to utilize the slot management component of Rental Control. In - particular the `packages` directory configuration as Rental Control - generates automations using a similar mechanism to Keymaster. - - **NOTE:** It is very important that the Keymaster slots that you are - going to manage are either completely clear when you setup the - integration _or_ that they follow the following rules: - - - The slot name == Prefix(if defined) Slot_name(per the event sensor) - - The slot code == the defined slot code matches what is currently in - the event sensor - - The start and stop dates and times match what are in the sensor - - Failing to follow these rules may cause your configuration to behave in - unexpected way. - + trying to utilize the slot management component of Rental Control. + - **NOTE:** The Keymaster slots that are defined as being managed will be + completely taken control of by Rental Control. Any data in the slots + will be overwritten by Rental Control when it takes over the slot unless + it matches event data for the calendar. - The following portions of a Keymaster slot will influence (that is override) data in the calendar or event sensor: - Checkin/out TIME (not date) will update the calendar event and also @@ -137,9 +172,6 @@ The integration is set up using the GUI. slot that has the same door code (or starting code, typically first 4 digits) that is the generated code and thus causing the slot to not function properly - - An additional "mapping" sensor will be generated when setup to manage a - lock. This sensor is primarily used for fireing events for the generated - automations to pick up. ## Reconfiguration @@ -156,5 +188,7 @@ the `...` menu next to `Configure` and select `Reload` ## Known issues -While the integration supports reconfiguration a few things are not presently -working correctly with this. If you are needing to change +While the integration supports reconfiguration a few things may not fully update +after a reconfiguration. If you are having issues with reconfigured options +not being picked up properly try reloading the particular integration +installation or restart Home Assistant. diff --git a/custom_components/rental_control/__init__.py b/custom_components/rental_control/__init__.py index 143bf49..1a31847 100644 --- a/custom_components/rental_control/__init__.py +++ b/custom_components/rental_control/__init__.py @@ -14,9 +14,10 @@ from __future__ import annotations import asyncio +import functools import logging -import re from datetime import datetime +from datetime import time from datetime import timedelta from typing import Any from typing import Dict @@ -24,8 +25,8 @@ import async_timeout import homeassistant.helpers.config_validation as cv -import icalendar import voluptuous as vol +from icalendar import Calendar from homeassistant.components.calendar import CalendarEvent from homeassistant.components.persistent_notification import async_create from homeassistant.components.persistent_notification import async_dismiss @@ -34,8 +35,10 @@ from homeassistant.const import CONF_URL from homeassistant.const import CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -from homeassistant.core import ServiceCall from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.event import ( + async_track_state_change_event, +) from homeassistant.helpers import device_registry as dr from homeassistant.util import dt @@ -57,22 +60,25 @@ from .const import CONF_REFRESH_FREQUENCY from .const import CONF_START_SLOT from .const import CONF_TIMEZONE +from .const import COORDINATOR from .const import DEFAULT_CODE_GENERATION from .const import DEFAULT_CODE_LENGTH +from .const import DEFAULT_GENERATE from .const import DEFAULT_REFRESH_FREQUENCY from .const import DOMAIN from .const import EVENT_RENTAL_CONTROL_REFRESH from .const import NAME from .const import PLATFORMS from .const import REQUEST_TIMEOUT +from .const import UNSUB_LISTENERS from .const import VERSION -from .services import generate_package_files -from .services import update_code_slot +from .sensors.calsensor import RentalControlCalSensor from .util import async_reload_package_platforms from .util import delete_rc_and_base_folder -from .util import fire_clear_code from .util import gen_uuid from .util import get_slot_name +from .util import handle_state_change +from .event_overrides import EventOverrides _LOGGER = logging.getLogger(__name__) @@ -88,9 +94,9 @@ def setup(hass, config): # pylint: disable=unused-argument return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up Rental Control from a config entry.""" - config = entry.data + config = config_entry.data _LOGGER.debug( "Running init async_setup_entry for calendar %s", config.get(CONF_NAME) ) @@ -99,71 +105,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: updated_config = config.copy() updated_config.pop(CONF_GENERATE, None) - if updated_config != entry.data: - hass.config_entries.async_update_entry(entry, data=updated_config) + if updated_config != config_entry.data: + hass.config_entries.async_update_entry(config_entry, data=updated_config) if DOMAIN not in hass.data: hass.data[DOMAIN] = {} - hass.data[DOMAIN][entry.unique_id] = RentalControl( - hass=hass, config=config, unique_id=entry.unique_id, entry_id=entry.entry_id + + coordinator = RentalControl( + hass=hass, + config_entry=config_entry, ) + hass.data[DOMAIN][config_entry.entry_id] = { + COORDINATOR: coordinator, + UNSUB_LISTENERS: [], + } + + # Start listeners if needed + await async_start_listener(hass, config_entry) + for component in PLATFORMS: hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, component) + hass.config_entries.async_forward_entry_setup(config_entry, component) ) - entry.add_update_listener(update_listener) - - # Generate package files - async def _generate_package(service: ServiceCall) -> None: - """Generate the package files.""" - _LOGGER.debug("In _generate_package: '%s'", service) - await generate_package_files(hass, service.data["rental_control_name"]) - - hass.services.async_register( - DOMAIN, - SERVICE_GENERATE_PACKAGE, - _generate_package, - ) - - # Update Code Slot - async def _update_code_slot(service: ServiceCall) -> None: - """Update code slot with Keymaster information.""" - _LOGGER.debug("Update Code Slot service: %s", service) + config_entry.add_update_listener(update_listener) - await update_code_slot(hass, service) - - hass.services.async_register( - DOMAIN, - SERVICE_UPDATE_CODE_SLOT, - _update_code_slot, - ) - - # generate files if needed + # remove files if needed if should_generate_package: - rc_name = config.get(CONF_NAME) - servicedata = {"rental_control_name": rc_name} - await hass.services.async_call( - DOMAIN, SERVICE_GENERATE_PACKAGE, servicedata, blocking=True - ) - - _LOGGER.debug("Firing refresh event") - # Fire an event for the startup automation to capture - hass.bus.fire( - EVENT_RENTAL_CONTROL_REFRESH, - event_data={ - ATTR_NOTIFICATION_SOURCE: "event", - ATTR_NAME: rc_name, - }, - ) + delete_rc_and_base_folder(hass, config_entry) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry): """Handle removal of an entry.""" - config = entry.data + config = config_entry.data rc_name = config.get(CONF_NAME) _LOGGER.debug("Running async_unload_entry for rental_control %s", rc_name) @@ -182,7 +159,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): unload_ok = all( await asyncio.gather( *[ - hass.config_entries.async_forward_entry_unload(entry, component) + hass.config_entries.async_forward_entry_unload(config_entry, component) for component in PLATFORMS ] ) @@ -190,11 +167,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): if unload_ok: # Remove all package files and the base folder if needed - await hass.async_add_executor_job(delete_rc_and_base_folder, hass, config) + await hass.async_add_executor_job(delete_rc_and_base_folder, hass, config_entry) await async_reload_package_platforms(hass) - hass.data[DOMAIN].pop(entry.unique_id) + # Unsubscribe from any listeners + for unsub_listener in hass.data[DOMAIN][config_entry.entry_id].get( + UNSUB_LISTENERS, [] + ): + unsub_listener() + hass.data[DOMAIN][config_entry.entry_id].get(UNSUB_LISTENERS, []).clear() + + hass.data[DOMAIN].pop(config_entry.entry_id) async_dismiss(hass, notification_id) @@ -218,6 +202,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> data=data, ) config_entry.version = 2 + version = 2 _LOGGER.debug("Migration to version %s complete", config_entry.version) # 2 -> 3: Migrate lock @@ -237,6 +222,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ) config_entry.version = 3 + version = 3 _LOGGER.debug("Migration to version %s complete", config_entry.version) # 3 -> 4: Migrate code length @@ -252,35 +238,78 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ) config_entry.version = 4 + version = 4 _LOGGER.debug("Migration to version %s complete", config_entry.version) + # 4 -> 5: Drop startup automation + if version == 4: + _LOGGER.debug(f"Migrating from version {version}") + + data = config_entry.data.copy() + data[CONF_GENERATE] = DEFAULT_GENERATE + hass.config_entries.async_update_entry( + entry=config_entry, + unique_id=config_entry.unique_id, + data=data, + ) + + config_entry.version = 5 + version = 5 + _LOGGER.debug(f"Migration to version {config_entry.version} complete") + + # 5 -> 6: Drop package_path from configuration + if version == 5: + _LOGGER.debug(f"Migrating from version {version}") + + data = config_entry.data.copy() + data.pop(CONF_PATH, None) + hass.config_entries.async_update_entry( + entry=config_entry, unique_id=config_entry.unique_id, data=data + ) + + config_entry.version = 6 + version = 6 + _LOGGER.debug(f"Migration to version {config_entry.version} complete") + return True -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Update listener.""" # No need to update if the options match the data - if not entry.options: + if not config_entry.options: return - new_data = entry.options.copy() + new_data = config_entry.options.copy() new_data.pop(CONF_GENERATE, None) - old_data = hass.data[DOMAIN][entry.unique_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] # do not update the creation datetime if it already exists (which it should) - new_data[CONF_CREATION_DATETIME] = old_data.created + new_data[CONF_CREATION_DATETIME] = coordinator.created hass.config_entries.async_update_entry( - entry=entry, - unique_id=entry.unique_id, + entry=config_entry, + unique_id=config_entry.unique_id, data=new_data, title=new_data[CONF_NAME], options={}, ) # Update the calendar config - hass.data[DOMAIN][entry.unique_id].update_config(new_data) + coordinator.update_config(new_data) + + # Unsubscribe to any listeners so we can create new ones + for unsub_listener in hass.data[DOMAIN][config_entry.entry_id].get( + UNSUB_LISTENERS, [] + ): + unsub_listener() + hass.data[DOMAIN][config_entry.entry_id].get(UNSUB_LISTENERS, []).clear() + + if coordinator.lockname: + await async_start_listener(hass, config_entry) + else: + _LOGGER.debug("Skipping re-adding listeners") # Update package files if new_data[CONF_LOCK_ENTRY]: @@ -301,67 +330,81 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: ) +async def async_start_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Start tracking updates to keymaster input entities.""" + entities: list[str] = [] + + _LOGGER.debug(f"entry_id = '{config_entry.unique_id}'") + + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + lockname = coordinator.lockname + + _LOGGER.debug(f"lockname = '{lockname}'") + + for i in range( + coordinator.start_slot, coordinator.start_slot + coordinator.max_events + ): + entities.append(f"input_text.{lockname}_pin_{i}") + entities.append(f"input_text.{lockname}_name_{i}") + entities.append(f"input_datetime.start_date_{lockname}_{i}") + entities.append(f"input_datetime.end_date_{lockname}_{i}") + + hass.data[DOMAIN][config_entry.entry_id][UNSUB_LISTENERS].append( + async_track_state_change_event( + hass, + [entity for entity in entities], + functools.partial(handle_state_change, hass, config_entry), + ) + ) + + class RentalControl: """Get a list of events.""" # pylint: disable=too-many-instance-attributes - def __init__(self, hass, config, unique_id, entry_id): + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry): """Set up a calendar object.""" - self.hass = hass - self._name = config.get(CONF_NAME) - self._unique_id = unique_id - self._entry_id = entry_id - self.event_prefix = config.get(CONF_EVENT_PREFIX) - self.url = config.get(CONF_URL) - # 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 + config = config_entry.data + self.hass: HomeAssistant = hass + self.config_entry: ConfigEntry = config_entry + self._name: str = config.get(CONF_NAME) + self._unique_id: str = config_entry.unique_id + self._entry_id: str = config_entry.entry_id + self.event_prefix: str = config.get(CONF_EVENT_PREFIX) + self.url: str = config.get(CONF_URL) + self.timezone: dt.tzinfo = ZoneInfo(config.get(CONF_TIMEZONE)) + self.refresh_frequency: int = config.get( + CONF_REFRESH_FREQUENCY, DEFAULT_REFRESH_FREQUENCY + ) # after initial setup our first refresh should happen ASAP - self.next_refresh = dt.now() + self.next_refresh: dt.datetime = 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.start_slot = config.get(CONF_START_SLOT) - self.lockname = config.get(CONF_LOCK_ENTRY) - self.max_events = config.get(CONF_MAX_EVENTS) - self.days = config.get(CONF_DAYS) - self.ignore_non_reserved = config.get(CONF_IGNORE_NON_RESERVED) - self.verify_ssl = config.get(CONF_VERIFY_SSL) - self.calendar = [] - self.calendar_ready = False - self.calendar_loaded = False - self.overrides_loaded = False - self.event_overrides = {} - self.event_sensors = [] - self.code_generator = config.get(CONF_CODE_GENERATION, DEFAULT_CODE_GENERATION) - self.code_length = config.get(CONF_CODE_LENGTH, DEFAULT_CODE_LENGTH) - self.event = None - self.all_day = False - self.created = config.get(CONF_CREATION_DATETIME, str(dt.now())) - self._version = VERSION - - # Alert users if they have a lock defined but no packages path - # this would happen if they've upgraded from an older version where - # they already had a lock definition defined even though it didn't - # do anything - self.path = config.get(CONF_PATH, None) - if self.path is None and self.lockname is not None: - notification_id = f"{DOMAIN}_{self._name}_missing_path" - async_create( - hass, - (f"Please update configuration for {NAME} {self._name}"), - title=f"{NAME} - Missing configuration", - notification_id=notification_id, - ) + self.checkin: time = cv.time(config.get(CONF_CHECKIN)) + self.checkout: time = cv.time(config.get(CONF_CHECKOUT)) + self.start_slot: int = config.get(CONF_START_SLOT) + self.lockname: str = config.get(CONF_LOCK_ENTRY) + self.max_events: int = config.get(CONF_MAX_EVENTS) + self.days: int = config.get(CONF_DAYS) + self.ignore_non_reserved: bool = config.get(CONF_IGNORE_NON_RESERVED) + self.verify_ssl: bool = config.get(CONF_VERIFY_SSL) + self.calendar: list[CalendarEvent] = [] + self.calendar_ready: bool = False + self.calendar_loaded: bool = False + self.overrides_loaded: bool = False + self.event_overrides: EventOverrides = EventOverrides( + self.start_slot, self.max_events + ) + self.event_sensors: list[RentalControlCalSensor] = [] + self._events_ready: bool = False + self.code_generator: str = config.get( + CONF_CODE_GENERATION, DEFAULT_CODE_GENERATION + ) + self.code_length: int = config.get(CONF_CODE_LENGTH, DEFAULT_CODE_LENGTH) + self.event: CalendarEvent = None + self.created: str = config.get(CONF_CREATION_DATETIME, str(dt.now())) + self._version: str = VERSION # setup device device_registry = dr.async_get(hass) @@ -382,20 +425,37 @@ def device_info(self) -> Dict[str, Any]: } @property - def name(self): + def name(self) -> str: """Return the name.""" return self._name @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique id.""" return self._unique_id @property - def version(self): + def version(self) -> str: """Return the version.""" return self._version + @property + def events_ready(self) -> bool: + """Return the status of all the event sensors""" + + # Once all events report ready we don't keep checking + if self._events_ready: + return self._events_ready + + # If all sensors have not yet been created we're still starting + if len(self.event_sensors) != self.max_events: + return self._events_ready + + sensors_status = [event.available for event in self.event_sensors] + self._events_ready = all(sensors_status) + + return self._events_ready + async def async_get_events( self, hass, start_date, end_date ) -> list[CalendarEvent]: # pylint: disable=unused-argument @@ -418,7 +478,7 @@ async def async_get_events( events.append(event) return events - async def update(self): + async def update(self) -> None: """Regularly update the calendar.""" _LOGGER.debug("Running RentalControl update for calendar %s", self.name) @@ -438,20 +498,35 @@ async def update(self): _LOGGER.debug("Updating next refresh to %s", self.next_refresh) await self._refresh_calendar() - def update_config(self, config): + # Get slot overrides on startup + if not self.calendar_ready and self.lockname: + for i in range(self.start_slot, self.start_slot + self.max_events): + slot_code = self.hass.states.get(f"input_text.{self.lockname}_pin_{i}") + slot_name = self.hass.states.get(f"input_text.{self.lockname}_name_{i}") + start_time = self.hass.states.get( + f"input_datetime.start_date_{self.lockname}_{i}" + ) + end_time = self.hass.states.get( + f"input_datetime.end_date_{self.lockname}_{i}" + ) + + await self.update_event_overrides( + i, + slot_code.as_dict()["state"], + slot_name.as_dict()["state"], + dt.parse_datetime(start_time.as_dict()["state"]), + dt.parse_datetime(end_time.as_dict()["state"]), + ) + + # always refresh the overrides + await self.event_overrides.async_check_overrides(self) + + def update_config(self, config) -> None: """Update config entries.""" self._name = config.get(CONF_NAME) self.url = config.get(CONF_URL) - # 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.timezone = ZoneInfo(config.get(CONF_TIMEZONE)) 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) @@ -464,28 +539,9 @@ def update_config(self, config): self.days = config.get(CONF_DAYS) self.code_generator = config.get(CONF_CODE_GENERATION, DEFAULT_CODE_GENERATION) self.code_length = config.get(CONF_CODE_LENGTH, DEFAULT_CODE_LENGTH) - # 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) - # make sure we have a path set - self.path = config.get(CONF_PATH, None) - - # This should not be possible during this phase! - if self.path is None and self.lockname is not None: - notification_id = f"{DOMAIN}_{self._name}_missing_path" - async_create( - self.hass, - (f"Please update configuration for {NAME} {self._name}"), - title=f"{NAME} - Missing configuration", - notification_id=notification_id, - ) - # updated the calendar in case the fetch days has changed self.calendar = self._refresh_event_dict() @@ -496,63 +552,27 @@ async def update_event_overrides( slot_name: str, start_time: datetime, end_time: datetime, - ): + ) -> None: """Update the event overrides with the ServiceCall data.""" _LOGGER.debug("In update_event_overrides") - event_overrides = self.event_overrides.copy() - - if slot_name: - _LOGGER.debug("Searching by slot_name: '%s'", slot_name) - regex = r"^(" + self.event_prefix + " )?(.*)$" - matches = re.findall(regex, slot_name) - if matches[0][1] not in event_overrides: - _LOGGER.debug("Event '%s' not in overrides", matches[0][1]) - for event in event_overrides.keys(): - if slot == event_overrides[event]["slot"]: - _LOGGER.debug("Slot '%d' is in event '%s'", slot, event) - del event_overrides[event] - break - - event_overrides[matches[0][1]] = { - "slot": slot, - "slot_code": slot_code, - "start_time": start_time, - "end_time": end_time, - } - else: - _LOGGER.debug("Searching by slot: '%s'", slot) - for event in event_overrides.keys(): - if slot == event_overrides[event]["slot"]: - _LOGGER.debug("Slot '%d' is in event '%s'", slot, event) - del event_overrides[event] - break - - event_overrides["Slot " + str(slot)] = { - "slot": slot, - } - - self.event_overrides = event_overrides - - _LOGGER.debug("event_overrides: '%s'", self.event_overrides) - if len(self.event_overrides) == self.max_events: - _LOGGER.debug("max_events reached, flagging as ready") - self.overrides_loaded = True - if self.calendar_loaded: - self.calendar_ready = True - else: - _LOGGER.debug( - "max_events not reached yet, calendar_ready is '%s'", - self.calendar_ready, - ) + # temporary call new_update_event_overrides + self.event_overrides.update( + slot, slot_code, slot_name, start_time, end_time, self.event_prefix + ) + + if self.event_overrides.ready and self.calendar_loaded: + self.calendar_ready = True # Overrides have updated, trigger refresh of calendar self.next_refresh = dt.now() - def _ical_parser(self, calendar, from_date, to_date): + async def _ical_parser( + self, calendar: Calendar, from_date: dt.datetime, to_date: dt.datetime + ) -> list[CalendarEvent]: """Return a sorted list of events from a icalendar object.""" - events = [] + events: list[CalendarEvent] = [] _LOGGER.debug( "In _ical_parser:: from_date: %s; to_date: %s", from_date, to_date @@ -598,26 +618,19 @@ def _ical_parser(self, calendar, from_date, to_date): if "DESCRIPTION" in event: slot_name = get_slot_name( - event["SUMMARY"], event["DESCRIPTION"], None + event["SUMMARY"], event["DESCRIPTION"], "" ) else: # VRBO and Booking.com do not have a DESCRIPTION element - slot_name = get_slot_name(event["SUMMARY"], None, None) + slot_name = get_slot_name(event["SUMMARY"], "", "") override = None - if slot_name and slot_name in self.event_overrides: - override = self.event_overrides[slot_name] - _LOGGER.debug("override: '%s'", override) - # If start and stop are the same, then we ignore the override - # This shouldn't happen except when a slot has been cleared - # In that instance we shouldn't find an override - if override["start_time"] == override["end_time"]: - _LOGGER.debug("override is now none") - override = None + if slot_name: + override = self.event_overrides.get_slot_with_name(slot_name) if override: - checkin = override["start_time"].time() - checkout = override["end_time"].time() + checkin: time = override["start_time"].time() + checkout: time = override["end_time"].time() else: checkin = self.checkin checkout = self.checkout @@ -638,15 +651,19 @@ def _ical_parser(self, calendar, from_date, to_date): if self.event_prefix: event["SUMMARY"] = self.event_prefix + " " + event["SUMMARY"] - cal_event = self._ical_event(start, end, from_date, event, override) + cal_event = await self._ical_event(start, end, from_date, event) if cal_event: events.append(cal_event) - sorted_events = sorted(events, key=lambda k: k.start) - return sorted_events + events.sort(key=lambda k: k.start) + return events - def _ical_event( - self, start, end, from_date, event, override + async def _ical_event( + self, + start: dt.datetime, + end: dt.datetime, + from_date: dt.datetime, + event: Dict[Any, Any], ) -> CalendarEvent | None: """Ensure that events are within the start and end.""" # Ignore events that ended this midnight. @@ -657,9 +674,6 @@ def _ical_event( and end.second == 0 ): _LOGGER.debug("This event has already ended") - if override: - _LOGGER.debug("Override exists for event, clearing slot") - fire_clear_code(self.hass, override["slot"], self._name) return None _LOGGER.debug( "Start: %s Tzinfo: %s Default: %s StartAs %s", @@ -685,7 +699,7 @@ def _ical_event( _LOGGER.debug("Event to add: %s", str(CalendarEvent)) return cal_event - def _refresh_event_dict(self): + def _refresh_event_dict(self) -> list[CalendarEvent]: """Ensure that all events in the calendar are start before max days.""" cal = self.calendar @@ -693,7 +707,7 @@ def _refresh_event_dict(self): return [x for x in cal if x.start.date() <= days.date()] - async def _refresh_calendar(self): + async def _refresh_calendar(self) -> None: """Update list of upcoming events.""" _LOGGER.debug("Running RentalControl _refresh_calendar for %s", self.name) @@ -704,15 +718,19 @@ async def _refresh_calendar(self): _LOGGER.error( "%s returned %s - %s", self.url, response.status, response.reason ) + + # Calendar has failed to load for some reason, unflag the calendar + # being loaded + self.calendar_loaded = False 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", "")) + event_list = 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( + self.calendar = await self._ical_parser( event_list, start_of_events, end_of_events ) @@ -735,3 +753,6 @@ async def _refresh_calendar(self): ) self.event = event found_next_event = True + + # signal an update to all the event sensors + await asyncio.gather(*[event.async_update() for event in self.event_sensors]) diff --git a/custom_components/rental_control/calendar.py b/custom_components/rental_control/calendar.py index 559c07a..11f1851 100644 --- a/custom_components/rental_control/calendar.py +++ b/custom_components/rental_control/calendar.py @@ -3,12 +3,16 @@ import logging from typing import Any +from typing import Dict from homeassistant.components.calendar import CalendarEntity from homeassistant.components.calendar import CalendarEvent -from homeassistant.const import CONF_NAME +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory +from . import RentalControl +from .const import COORDINATOR from .const import DOMAIN from .const import NAME from .util import gen_uuid @@ -17,40 +21,47 @@ OFFSET = "!!" -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities +) -> bool: """Set up the iCal Calendar platform.""" config = config_entry.data _LOGGER.debug("Running setup_platform for calendar") _LOGGER.debug("Conf: %s", config) - name = config.get(CONF_NAME) - rental_control_events = hass.data[DOMAIN][config_entry.unique_id] + coordinator: RentalControl = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - calendar = RentalControlCalendar(hass, f"{NAME} {name}", rental_control_events) + calendar = RentalControlCalendar(coordinator) async_add_entities([calendar], True) + return True + class RentalControlCalendar(CalendarEntity): """A device for getting the next Task from a WebDav Calendar.""" - def __init__( - self, hass, name, rental_control_events - ): # pylint: disable=unused-argument + def __init__(self, coordinator: RentalControl) -> None: """Create the iCal Calendar Event Device.""" - self._entity_category = EntityCategory.DIAGNOSTIC - self._event = None - self._name = name - self.rental_control_events = rental_control_events - self._unique_id = gen_uuid(f"{self.rental_control_events.unique_id} calendar") + self._available: bool = False + self._entity_category: EntityCategory = EntityCategory.DIAGNOSTIC + self._event: CalendarEvent | None = None + self._name: str = f"{NAME} {coordinator.name}" + self.coordinator: RentalControl = coordinator + self._unique_id: str = gen_uuid(f"{self.coordinator.unique_id} calendar") + + @property + def available(self) -> bool: + """Return the calendar availablity.""" + return self._available @property - def device_info(self): + def device_info(self) -> Dict[str, Any]: """Return the device info block.""" - return self.rental_control_events.device_info + return self.coordinator.device_info @property - def entity_category(self): + def entity_category(self) -> EntityCategory: """Return the category.""" return self._entity_category @@ -60,24 +71,25 @@ def event(self) -> CalendarEvent | None: return self._event @property - def name(self): + def name(self) -> str: """Return the name of the entity.""" return self._name @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique_id.""" return self._unique_id async def async_get_events(self, hass, start_date, end_date) -> Any: """Get all events in a specific time frame.""" _LOGGER.debug("Running RentalControlCalendar async get events") - return await self.rental_control_events.async_get_events( - hass, start_date, end_date - ) + return await self.coordinator.async_get_events(hass, start_date, end_date) - async def async_update(self): + async def async_update(self) -> None: """Update event data.""" _LOGGER.debug("Running RentalControlCalendar async update for %s", self.name) - await self.rental_control_events.update() - self._event = self.rental_control_events.event + await self.coordinator.update() + self._event = self.coordinator.event + + if self.coordinator.calendar_ready: + self._available = True diff --git a/custom_components/rental_control/config_flow.py b/custom_components/rental_control/config_flow.py index c8c4ee8..aa7caed 100644 --- a/custom_components/rental_control/config_flow.py +++ b/custom_components/rental_control/config_flow.py @@ -1,6 +1,5 @@ """Config flow for Rental Control integration.""" import logging -import os import re from typing import Any from typing import Dict @@ -33,7 +32,6 @@ from .const import CONF_IGNORE_NON_RESERVED from .const import CONF_LOCK_ENTRY from .const import CONF_MAX_EVENTS -from .const import CONF_PATH from .const import CONF_REFRESH_FREQUENCY from .const import CONF_START_SLOT from .const import CONF_TIMEZONE @@ -45,7 +43,6 @@ from .const import DEFAULT_EVENT_PREFIX from .const import DEFAULT_GENERATE from .const import DEFAULT_MAX_EVENTS -from .const import DEFAULT_PATH from .const import DEFAULT_REFRESH_FREQUENCY from .const import DEFAULT_START_SLOT from .const import DOMAIN @@ -63,7 +60,7 @@ class RentalControlFlowHandler(config_entries.ConfigFlow): """Handle the config flow for Rental Control.""" - VERSION = 3 + VERSION = 6 DEFAULTS = { CONF_CHECKIN: DEFAULT_CHECKIN, @@ -277,9 +274,6 @@ def _get_default(key: str, fallback_default: Any = None) -> Any | None: to_type=False, ), ): vol.In(_code_generators()), - vol.Required( - CONF_PATH, default=_get_default(CONF_PATH, DEFAULT_PATH) - ): cv.string, vol.Optional( CONF_IGNORE_NON_RESERVED, default=_get_default(CONF_IGNORE_NON_RESERVED, True), @@ -390,10 +384,6 @@ async def _start_config_flow( ident=user_input[CONF_CODE_GENERATION], to_type=True ) - # Validate that path is relative - if os.path.isabs(user_input[CONF_PATH]): - errors[CONF_PATH] = "invalid_path" - if not errors: # Only do this conversion if there are no errors and it needs to be # done. Doing this before the errors check will lead to later diff --git a/custom_components/rental_control/const.py b/custom_components/rental_control/const.py index f9344f5..f6bdef6 100644 --- a/custom_components/rental_control/const.py +++ b/custom_components/rental_control/const.py @@ -15,6 +15,10 @@ ICON = "mdi:account-key" MAP_ICON = "mdi:map-search" +# hass.data attributes +COORDINATOR = "coordinator" +UNSUB_LISTENERS = "unsub_listeners" + # Platforms CALENDAR = "calendar" SENSOR = "sensor" diff --git a/custom_components/rental_control/event_overrides.py b/custom_components/rental_control/event_overrides.py new file mode 100644 index 0000000..ad00f09 --- /dev/null +++ b/custom_components/rental_control/event_overrides.py @@ -0,0 +1,300 @@ +# SPDX-License-Identifier: Apache-2.0 +############################################################################## +# COPYRIGHT 2023 Andrew Grimberg +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache 2.0 License +# which accompanies this distribution, and is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Contributors: +# Andrew Grimberg - Initial implementation +############################################################################## +"""Rental Control EventOVerrides.""" +import asyncio +import logging +import re +from datetime import datetime +from typing import Dict +from typing import List +from typing import TypedDict + +from homeassistant.util import dt + +from .util import async_fire_clear_code +from .util import get_event_names + + +_LOGGER = logging.getLogger(__name__) + + +class EventOverride(TypedDict): + """Event override definition.""" + + slot_name: str + slot_code: str + start_time: datetime + end_time: datetime + + +class EventOverrides: + """Event Overrides object and methods.""" + + def __init__(self, start_slot: int, max_slots: int) -> None: + """Setup the overrides object.""" + + self._max_slots: int = max_slots + self._next_slot: int | None = None + self._overrides: Dict[int, EventOverride | None] = {} + self._ready: bool = False + self._start_slot: int = start_slot + + @property + def max_slots(self) -> int: + """Return the max_slots known.""" + return self._max_slots + + @property + def next_slot(self) -> int | None: + """Return the next_slot available.""" + return self._next_slot + + @property + def overrides(self) -> Dict[int, EventOverride | None]: + """Return the overrides.""" + return self._overrides + + @property + def ready(self) -> bool: + """Return if the overrides are ready.""" + return self._ready + + @property + def start_slot(self) -> int: + """Return the start_slot.""" + return self._start_slot + + def __assign_next_slot(self) -> None: + """Assign the next slot.""" + + _LOGGER.debug("In EventOverrides.assign_next_slot") + + if len(self._overrides) != self.max_slots: + _LOGGER.debug("System starting up") + return + + slots_with_values = self.__get_slots_with_values() + if len(slots_with_values) == self.max_slots: + _LOGGER.debug("Overrides at max") + self._next_slot = None + return + + if len(slots_with_values): + max_slot = slots_with_values[-1] + else: + max_slot = self.start_slot - 1 + + # Get all the available slots greater than our current max + avail_slots = self.__get_slots_without_values(max_slot) + if len(avail_slots): + _LOGGER.debug(f"Next slot is {avail_slots[0]}") + self._next_slot = avail_slots[0] + return + + # Slots greater than our current max don't work, so find the first free + # slot + avail_slots = self.__get_slots_without_values() + + if len(avail_slots): + _LOGGER.debug(f"Next slot is {avail_slots[0]}") + self._next_slot = avail_slots[0] + return + + # We should never hit this directly, but if we do, set our next to None + self._next_slot = None + + def __get_slots_with_values(self) -> List[int]: + """Get a sorted list of the keys that have values.""" + return sorted( + k for k in self._overrides.keys() if self._overrides[k] is not None + ) + + def __get_slots_without_values(self, max_slot: int = 0) -> List[int]: + """ + Get the sorted list of the keys that have no value greater than + max_slot. + """ + return sorted( + k + for k in self._overrides.keys() + if self._overrides[k] is None and k > max_slot + ) + + async def async_check_overrides(self, coordinator) -> None: + """Check if overrides need to have a clear_code event fired.""" + + _LOGGER.debug("In EventOverrides.async_check_overrides") + + calendar = coordinator.calendar + + if not coordinator.calendar_loaded or not coordinator.events_ready: + _LOGGER.debug( + "Calendar or events not loaded, not checking override validity" + ) + return + + _LOGGER.debug(self._overrides) + event_names = get_event_names(coordinator) + _LOGGER.debug(f"event_names = {event_names}") + + assigned_slots = self.__get_slots_with_values() + + if not len(assigned_slots): + _LOGGER.debug("No overrides to check") + return + + cur_date_start = dt.start_of_local_day().date() + + for slot in assigned_slots: + clear_code = False + + if self.get_slot_name(slot) not in event_names: + _LOGGER.debug( + f"{self._overrides[slot]} not in current events, clearing" + ) + clear_code = True + + start_time = self.get_slot_start_time(slot).date() + end_time = self.get_slot_end_time(slot).date() + + if not len(calendar): + _LOGGER.debug(f"No events in calendar, clearing {slot}") + clear_code = True + + if not clear_code and start_time > end_time: + _LOGGER.debug(f"{slot} start and end times do not make sense, clearing") + clear_code = True + + if not clear_code and end_time < cur_date_start: + _LOGGER.debug(f"{slot} end is before today, clearing") + clear_code = True + + if not clear_code: + if coordinator.max_events <= len(calendar): + last_end = calendar[coordinator.max_events - 1].end.date() + else: + last_end = calendar[-1].end.date() + + if start_time > last_end: + _LOGGER.debug(f"{slot} start is after last event ends, clearing") + clear_code = True + + if clear_code: + _LOGGER.debug(f"Firing clear code for slot {slot}") + await async_fire_clear_code(coordinator, slot) + + # signal an update to all the event sensors + await asyncio.gather( + *[event.async_update() for event in coordinator.event_sensors] + ) + + def get_slot_name(self, slot: int) -> str: + """Return the slot name.""" + override = self._overrides[slot] + + if override and "slot_name" in override: + return override["slot_name"] + else: + return "" + + def get_slot_with_name(self, slot_name: str) -> EventOverride | None: + """ + Find the override that has slot_name and return the data if + available. + """ + + slots_with_values = self.__get_slots_with_values() + for slot in slots_with_values: + override = self.overrides[slot] + if override and override["slot_name"] == slot_name: + return override + + return None + + def get_slot_key_by_name(self, slot_name: str) -> int: + """ + Find the override that has slot_name and return the data if + available. + + Returns 0 if no slot with name is found + """ + + slots_with_values = self.__get_slots_with_values() + for slot in slots_with_values: + override = self.overrides[slot] + if override and override["slot_name"] == slot_name: + return slot + + return 0 + + def get_slot_start_time(self, slot: int) -> datetime: + """Return the start datetime of slot or the start of day if no override.""" + + override = self._overrides[slot] + + if override: + if "start_time" in override: + return override["start_time"] + else: + # because HA doesn't ship type hints we have to ignore this + # particular type validation + return dt.start_of_local_day() # type: ignore + + def get_slot_end_time(self, slot: int) -> datetime: + """Return the end datetime of slot or the start of day if no override.""" + + override = self._overrides[slot] + + if override: + if "end_time" in override: + return override["end_time"] + else: + # because HA doesn't ship type hints we have to ignore this + # particular type validation + return dt.start_of_local_day() # type: ignore + + def update( + self, + slot: int, + slot_code: str, + slot_name: str, + start_time: datetime, + end_time: datetime, + prefix: str, + ) -> None: + """Update overrides.""" + + _LOGGER.debug("In EventOverrides.update") + + overrides = self._overrides.copy() + + if slot_name: + regex = r"^(" + prefix + " )?(.*)$" + matches = re.findall(regex, slot_name) + overrides[slot] = { + "slot_name": matches[0][1], + "slot_code": slot_code, + "start_time": start_time, + "end_time": end_time, + } + else: + overrides[slot] = None + + self._overrides = overrides + self.__assign_next_slot() + if len(overrides) == self.max_slots: + self._ready = True + + _LOGGER.debug(f"overrides = {self.overrides}") + _LOGGER.debug(f"ready = {self.ready}") + _LOGGER.debug(f"next_slot = {self.next_slot}") diff --git a/custom_components/rental_control/manifest.json b/custom_components/rental_control/manifest.json index 6f44a99..dc823c9 100644 --- a/custom_components/rental_control/manifest.json +++ b/custom_components/rental_control/manifest.json @@ -6,8 +6,9 @@ "config_flow": true, "dependencies": [], "documentation": "https://github.com/tykeal/homeassistant-rental-control", + "integration_type": "service", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/tykeal/homeassistant-rental-control/issues", - "requirements": ["icalendar==4.0.7"], + "requirements": ["icalendar==5.0.7"], "version": "v0.0.0" } diff --git a/custom_components/rental_control/sensor.py b/custom_components/rental_control/sensor.py index 1163e74..14df368 100644 --- a/custom_components/rental_control/sensor.py +++ b/custom_components/rental_control/sensor.py @@ -6,10 +6,10 @@ from homeassistant.const import CONF_NAME from .const import CONF_MAX_EVENTS +from .const import COORDINATOR from .const import DOMAIN from .const import NAME from .sensors.calsensor import RentalControlCalSensor -from .sensors.mapsensor import RentalControlMappingSensor _LOGGER = logging.getLogger(__name__) @@ -27,9 +27,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): name = config.get(CONF_NAME) max_events = config.get(CONF_MAX_EVENTS) - rental_control_events = hass.data[DOMAIN][config_entry.unique_id] - await rental_control_events.update() - if rental_control_events.calendar is None: + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + await coordinator.update() + if coordinator.calendar is None: _LOGGER.error("Unable to fetch iCal") return False @@ -38,19 +38,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): sensors.append( RentalControlCalSensor( hass, - rental_control_events, + coordinator, f"{NAME} {name}", eventnumber, ) ) - if rental_control_events.lockname: - sensors.append( - RentalControlMappingSensor( - hass, - rental_control_events, - f"{NAME} {name}", - ) - ) - async_add_entities(sensors) diff --git a/custom_components/rental_control/sensors/calsensor.py b/custom_components/rental_control/sensors/calsensor.py index 2288e91..201dcea 100644 --- a/custom_components/rental_control/sensors/calsensor.py +++ b/custom_components/rental_control/sensors/calsensor.py @@ -10,6 +10,8 @@ from homeassistant.helpers.entity import EntityCategory from ..const import ICON +from ..util import async_fire_set_code +from ..util import async_fire_update_times from ..util import gen_uuid from ..util import get_slot_name @@ -25,21 +27,21 @@ class RentalControlCalSensor(Entity): upcoming event. """ - def __init__(self, hass, rental_control_events, sensor_name, event_number): + def __init__(self, hass, coordinator, sensor_name, event_number): """ Initialize the sensor. sensor_name is typically the name of the calendar. eventnumber indicates which upcoming event this is, starting at zero """ - self.rental_control_events = rental_control_events - self.rental_control_events.event_sensors.append(self) - if rental_control_events.event_prefix: - summary = f"{rental_control_events.event_prefix} No reservation" + self.coordinator = coordinator + self.coordinator.event_sensors.append(self) + if coordinator.event_prefix: + summary = f"{coordinator.event_prefix} No reservation" else: summary = "No reservation" - self._code_generator = rental_control_events.code_generator - self._code_length = rental_control_events.code_length + self._code_generator = coordinator.code_generator + self._code_length = coordinator.code_length self._entity_category = EntityCategory.DIAGNOSTIC self._event_attributes = { "summary": summary, @@ -60,7 +62,7 @@ def __init__(self, hass, rental_control_events, sensor_name, event_number): self._name = f"{sensor_name} Event {self._event_number}" self._state = summary self._unique_id = gen_uuid( - f"{self.rental_control_events.unique_id} sensor {self._event_number}" + f"{self.coordinator.unique_id} sensor {self._event_number}" ) def _extract_email(self) -> str | None: @@ -208,7 +210,7 @@ def available(self): @property def device_info(self): """Return the device info block.""" - return self.rental_control_events.device_info + return self.coordinator.device_info @property def entity_category(self): @@ -245,15 +247,17 @@ async def async_update(self): """Update the sensor.""" _LOGGER.debug("Running RentalControlCalSensor async update for %s", self.name) - await self.rental_control_events.update() - # Calendar is not ready, no reason to continue processing - if not self.rental_control_events.calendar_ready: + if not self.coordinator.calendar_ready: return - self._code_generator = self.rental_control_events.code_generator - self._code_length = self.rental_control_events.code_length - event_list = self.rental_control_events.calendar + set_code = False + update_times = False + overrides = self.coordinator.event_overrides + + self._code_generator = self.coordinator.code_generator + self._code_length = self.coordinator.code_length + event_list = self.coordinator.calendar if event_list and (self._event_number < len(event_list)): event = event_list[self._event_number] name = event.summary @@ -291,20 +295,23 @@ async def async_update(self): slot_name = get_slot_name( self._event_attributes["summary"], self._event_attributes["description"], - self.rental_control_events.event_prefix, + self.coordinator.event_prefix, ) self._event_attributes["slot_name"] = slot_name - override = None - if slot_name and slot_name in self.rental_control_events.event_overrides: - override = self.rental_control_events.event_overrides[slot_name] - _LOGGER.debug("override: '%s'", override) - # If start and stop are the same, then we ignore the override - # This shouldn't happen except when a slot has been cleared - # In that instance we shouldn't find an override - if override["start_time"] == override["end_time"]: - _LOGGER.debug("override is now none") - override = None + override = overrides.get_slot_with_name(slot_name) + if override is None: + set_code = True + + if ( + not set_code + and override + and ( + override["start_time"].date() != event.start.date() + or override["end_time"].date() != event.end.date() + ) + ): + update_times = True if override and override["slot_code"]: slot_code = str(override["slot_code"]) @@ -336,6 +343,18 @@ async def async_update(self): parsed_attributes["reservation_url"] = reservation_url self._parsed_attributes = parsed_attributes + + # fire set_code if not in current overrides + if set_code: + await async_fire_set_code( + self.coordinator, + self, + self.coordinator.event_overrides.next_slot, + ) + + if update_times: + await async_fire_update_times(self.coordinator, self) + else: # No reservations _LOGGER.debug( @@ -343,8 +362,8 @@ async def async_update(self): str(self._event_number), self.name, ) - if self.rental_control_events.event_prefix: - summary = f"{self.rental_control_events.event_prefix} No reservation" + if self.coordinator.event_prefix: + summary = f"{self.coordinator.event_prefix} No reservation" else: summary = "No reservation" self._event_attributes = { @@ -362,4 +381,4 @@ async def async_update(self): self._parsed_attributes = {} self._state = summary - self._is_available = self.rental_control_events.calendar_ready + self._is_available = self.coordinator.calendar_ready diff --git a/custom_components/rental_control/sensors/mapsensor.py b/custom_components/rental_control/sensors/mapsensor.py deleted file mode 100644 index 8072b02..0000000 --- a/custom_components/rental_control/sensors/mapsensor.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Mapping sensor for slot to event.""" -from __future__ import annotations - -import logging -from typing import Any - -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity import EntityCategory - -from ..const import MAP_ICON -from ..util import async_check_overrides -from ..util import fire_set_code -from ..util import gen_uuid -from ..util import get_event_names - -_LOGGER = logging.getLogger(__name__) - - -class RentalControlMappingSensor(Entity): - """ - A sensor that defines the mapping of door code slots to events - """ - - def __init__(self, hass: HomeAssistant, rental_control, sensor_name): - """ - Initialize the sensor. - - sensor_name is typically the name of the calendar - """ - self.rental_control = rental_control - - self._entity_category = EntityCategory.DIAGNOSTIC - self._is_available = False - self._mapping_attributes = { - "prefix": self.rental_control.event_prefix, - "mapping": {}, - } - for i in range( - self.rental_control.start_slot, - self.rental_control.start_slot + self.rental_control.max_events, - ): - self._mapping_attributes["mapping"][i] = None - - self._name = f"{sensor_name} Mapping" - self._state = "Ready" - self._startup_count = 0 - self._unique_id = gen_uuid(f"{self.rental_control.unique_id} mapping sensor") - - @property - def available(self) -> bool: - """Return True if sensor is ready.""" - return self._is_available - - @property - def device_info(self) -> Any: - """Return the device info block.""" - return self.rental_control.device_info - - @property - def entity_category(self) -> EntityCategory: - """Return the entity category.""" - return self._entity_category - - @property - def extra_state_attributes(self) -> dict: - """Return the mapping attributes.""" - return self._mapping_attributes - - @property - def icon(self) -> str: - """Return the icon for the frontend.""" - return MAP_ICON - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name - - @property - def state(self) -> str: - """Return the mapping state.""" - return self._state - - @property - def unique_id(self) -> str: - """Return the unique id.""" - return self._unique_id - - async def async_update(self): - """Update the sensor.""" - _LOGGER.debug( - "Running RentalControlMappingSensor async_udpate for %s", self.name - ) - - # Do nothing if the rc calendar is not ready - if not self.rental_control.calendar_ready: - _LOGGER.debug("calendar not ready, skipping mapping update") - return - - # Do not execute until everything has had a chance to fully stabilize - # This can take a couple of minutes - if self._startup_count < 2: - _LOGGER.debug("Rental Control still starting, skipping mapping update") - self._startup_count += 1 - return - - # Make sure overrides are accurate - await async_check_overrides(self.rental_control) - - overrides = self.rental_control.event_overrides.copy() - - _LOGGER.debug("Current event_overrides: %s", overrides) - _LOGGER.debug( - "Current mapping attributes: %s", self._mapping_attributes["mapping"] - ) - - for override in overrides: - if "Slot " not in override: - self._mapping_attributes["mapping"][ - overrides[override]["slot"] - ] = override - else: - self._mapping_attributes["mapping"][overrides[override]["slot"]] = None - - _LOGGER.debug( - "Updated mapping attributes: %s", self._mapping_attributes["mapping"] - ) - - slots = filter(lambda k: ("Slot " in k), overrides) - for event in get_event_names(self.rental_control): - if event not in overrides: - try: - slot = next(slots) - _LOGGER.debug( - "%s is not in overrides, setting to slot %s", event, slot - ) - fire_set_code( - self.rental_control.hass, - self.rental_control.name, - overrides[slot]["slot"], - event, - ) - except StopIteration: - pass - self._is_available = self.rental_control.calendar_ready diff --git a/custom_components/rental_control/services.py b/custom_components/rental_control/services.py deleted file mode 100644 index 0cf8299..0000000 --- a/custom_components/rental_control/services.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Services for RentalControl""" -from __future__ import annotations - -import logging -import os -from pprint import pformat - -from homeassistant.components.persistent_notification import create -from homeassistant.core import HomeAssistant -from homeassistant.core import ServiceCall -from homeassistant.util import dt -from homeassistant.util import slugify - -from .const import DOMAIN -from .const import NAME -from .util import async_reload_package_platforms -from .util import write_template_config - -# from .util import reload_package_platforms - -_LOGGER = logging.getLogger(__name__) - - -async def generate_package_files(hass: HomeAssistant, rc_name: str) -> None: - """Generate the package files.""" - _LOGGER.debug("In generate_package_files: '%s'", rc_name) - - config_entry = None - for entry_id in hass.data[DOMAIN]: - if hass.data[DOMAIN][entry_id].name == rc_name: - config_entry = hass.data[DOMAIN][entry_id] - break - - _LOGGER.debug("config_entry is '%s'", config_entry) - _LOGGER.debug(pformat(config_entry.__dict__)) - if not config_entry: - raise ValueError(f"Couldn't find existing Rental Control entry for {rc_name}") - - rc_name_slug = slugify(rc_name) - - _LOGGER.debug("Starting file generation...") - - create( - hass, - ( - f"Package file genreation for `{rc_name}` has started. Once complete, " - "we will attempt to automatically update Home Assistant to avoid " - "requiring a full restart." - ), - title=f"{NAME} {rc_name} - Starting file generation", - ) - - output_path = os.path.join(hass.config.path(), config_entry.path, rc_name_slug) - - # If packages folder exists, delete it so we can recreate it - if os.path.isdir(output_path): - _LOGGER.debug("Directory %s already exists, cleaning it up", output_path) - for file in os.listdir(output_path): - os.remove(os.path.join(output_path, file)) - else: - _LOGGER.debug("Creating pacakges directory %s", output_path) - try: - os.makedirs(output_path) - except Exception as err: - _LOGGER.critical("Error creating directory: %s", str(err)) - - _LOGGER.debug("Packages directory is ready for file generation") - - templates = ["startup", "set_code", "update", "clear_code"] - - for t in templates: - write_template_config(output_path, t, NAME, rc_name, config_entry) - - platform_reloaded = await async_reload_package_platforms(hass) - - if platform_reloaded: - # if reload_package_platforms(hass): - create( - hass, - ( - f"Package generation for `{rc_name}` complete!\n\n" - "All changes have beena automatically applied, so no restat is needed." - ), - title=f"{NAME} {rc_name} - Package file generation complete!", - ) - _LOGGER.debug( - "Package generation complete and all changes have been hot reloaded" - ) - else: - create( - hass, - ( - f"Package generation for `{rc_name}` complete!\n\n" - "Changes couldn't be automatically applied, so a Home Assistant " - "restart is needed to fully apply the changes." - ), - title=f"{NAME} {rc_name} - Package file generation complete!", - ) - _LOGGER.debug("Package generation complete, Home Assistant restart needed") - - -async def update_code_slot( - hass: HomeAssistant, - service: ServiceCall, -) -> None: - """Update RentalControl with start and end times of a given slot.""" - _LOGGER.debug("In update_code_slot") - _LOGGER.debug("Service: '%s'", service) - - if "lockname" in service.data: - lockname = service.data["lockname"] - else: - lockname = None - - if "slot" in service.data: - slot = service.data["slot"] - else: - slot = 0 - - if "slot_code" in service.data and service.data["slot_code"]: - slot_code = service.data["slot_code"] - else: - slot_code = None - - if "slot_name" in service.data and service.data["slot_name"]: - slot_name = service.data["slot_name"] - else: - slot_name = None - - if "start_time" in service.data: - start_time = dt.parse_datetime(service.data["start_time"]) - else: - start_time = dt.start_of_local_day() - - if "end_time" in service.data: - end_time = dt.parse_datetime(service.data["end_time"]) - else: - end_time = dt.start_of_local_day() - - # Return on bad data or nothing to do - if slot == 0 or not lockname: - _LOGGER.debug("Nothing to do") - return None - - # Search for which device - for uid in hass.data[DOMAIN]: - rc = hass.data[DOMAIN][uid] - _LOGGER.debug( - """rc.start_slot: '%s' - rc.max_events: '%s' - combined: '%s' - name: '%s' - rc.lockname: '%s'""", - rc.start_slot, - rc.max_events, - rc.start_slot + rc.max_events, - rc.name, - rc.lockname, - ) - if ( - slot >= rc.start_slot - and slot < rc.start_slot + rc.max_events - and lockname == rc.lockname - ): - _LOGGER.debug("rc for slot: '%s', calling update_event_overrides", rc.name) - await rc.update_event_overrides( - slot, slot_code, slot_name, start_time, end_time - ) - break diff --git a/custom_components/rental_control/services.yaml b/custom_components/rental_control/services.yaml deleted file mode 100644 index de08803..0000000 --- a/custom_components/rental_control/services.yaml +++ /dev/null @@ -1,61 +0,0 @@ ---- -# yamllint disable rule:line-length - -generate_package: - name: Generate Rental Control files - description: (Re-)Generates the package files that are used to provide lock integration with Keymaster via automations - fields: - rental_control_name: - name: Rental Control Name - description: The name of the Rental Control device to generate for - example: Guest Room - required: true - selector: - text: - -update_code_slot: - name: Update code slot information - description: Update the data that RentalControl has about a given code slot - fields: - lockname: - name: Lock - description: The lock name that you specified during RentalControl configuration. - example: frontdoor - required: true - selector: - text: - slot: - name: Code Slot - description: The code slot that you are updating RentalControl about - example: 11 - required: true - selector: - text: - slot_code: - name: Door code - description: The code currently assigned to the slot - example: 1234 - required: true - selector: - text: - slot_name: - name: Code Slot Name - description: The name information on the code slot that you are updating RentalControl about - example: Mary - required: true - selector: - text: - start_time: - name: Slot Start Time - description: The starting time of the slot that you are updating RentalControl about (just the time) - example: 16:00 - required: true - selector: - datetime: - end_time: - name: Slot End Time - description: The ending time of the slot that you are updating RentalControl about (just the time) - example: 11:00 - required: true - selector: - datetime: diff --git a/custom_components/rental_control/templates/clear_code.yaml.j2 b/custom_components/rental_control/templates/clear_code.yaml.j2 deleted file mode 100644 index 1b87ab4..0000000 --- a/custom_components/rental_control/templates/clear_code.yaml.j2 +++ /dev/null @@ -1,18 +0,0 @@ ---- -automation: - id: "{{ config_entry["unique_id"] }} - {{ NAME }} - Clear code slot {{ rc_name }}" - alias: "{{ NAME }} - Clear code slot {{ rc_name }}" - description: "" - trigger: - - platform: event - event_type: rental_control_clear_code - event_data: - rental_control_name: "{{ rc_name }}" - condition: [] - action: - service: input_boolean.toggle - data: {} - target: - entity_id: input_boolean.reset_codeslot_{{ config_entry["lockname"] }}_{% raw %}{{ trigger.event.data.code_slot }}{% endraw %} - mode: queued - max: {{ config_entry["max_events"]|int + 2 }} diff --git a/custom_components/rental_control/templates/set_code.yaml.j2 b/custom_components/rental_control/templates/set_code.yaml.j2 deleted file mode 100644 index abec035..0000000 --- a/custom_components/rental_control/templates/set_code.yaml.j2 +++ /dev/null @@ -1,53 +0,0 @@ ---- -automation: - id: "{{ config_entry["unique_id"] }} - {{ NAME }} - Set code {{ rc_name }}" - alias: {{ NAME }} - Set Code {{ rc_name }} - description: "" - mode: queued - max: {{ config_entry["max_events"]|int + 2 }} - trigger: - - platform: event - event_type: rental_control_set_code - event_data: - rental_control_name: "{{ rc_name }}" - variables: - slot_name: '{% raw %}{{ trigger.event.data.slot_name }}{% endraw %}' - code_slot: '{% raw %}{{ trigger.event.data.code_slot }}{% endraw %}' - event_num: |- - {% raw %}{%- for i in range(0, {% endraw %}{{ config_entry["max_events"] }}{% raw %}) -%} - {%- {% endraw %}if state_attr("sensor.rental_control_{{ rc_slug }}_event_" + i|string, "slot_name") == slot_name {% raw %}-%} - {{ i }} - {%- endif -%} - {%- endfor -%}{% endraw %} - condition: [] - action: - - service: input_datetime.set_datetime - data: - datetime: '{% raw %}{{{% endraw %} state_attr("sensor.rental_control_{{ rc_slug }}_event_" + event_num|string, "end") {% raw %}}}{% endraw %}' - target: - entity_id: - - 'input_datetime.end_date_{{ config_entry["lockname"] }}_{% raw %}{{{% endraw %} code_slot {% raw %}}}{% endraw %}' - - service: input_datetime.set_datetime - data: - datetime: '{% raw %}{{{% endraw %} state_attr("sensor.rental_control_{{ rc_slug }}_event_" + event_num|string, "start") {% raw %}}}{% endraw %}' - target: - entity_id: - - 'input_datetime.start_date_{{ config_entry["lockname"] }}_{% raw %}{{{% endraw %} code_slot {% raw %}}}{% endraw %}' - - service: input_text.set_value - data: - value: '{% raw %}{{{% endraw %} state_attr("sensor.rental_control_{{ rc_slug }}_event_" + event_num|string, "slot_code") {% raw %}}}{% endraw %}' - target: - entity_id: - - 'input_text.{{ config_entry["lockname"] }}_pin_{% raw %}{{{% endraw %} code_slot {% raw %}}}{% endraw %}' - - service: input_text.set_value - data: - value: '{%- if config_entry["event_prefix"] %}{{ config_entry["event_prefix"] }} {% endif %}{% raw %}{{{% endraw %} slot_name {% raw %}}}{% endraw %}' - target: - entity_id: - - 'input_text.{{ config_entry["lockname"] }}_name_{% raw %}{{{% endraw %} code_slot {% raw %}}}{% endraw %}' - - service: input_boolean.turn_on - data: {} - target: - entity_id: - - 'input_boolean.daterange_{{ config_entry["lockname"] }}_{% raw %}{{{% endraw %} code_slot {% raw %}}}{% endraw %}' - - 'input_boolean.enabled_{{ config_entry["lockname"] }}_{% raw %}{{{% endraw %} code_slot {% raw %}}}{% endraw %}' diff --git a/custom_components/rental_control/templates/startup.yaml.j2 b/custom_components/rental_control/templates/startup.yaml.j2 deleted file mode 100644 index 55ffbf1..0000000 --- a/custom_components/rental_control/templates/startup.yaml.j2 +++ /dev/null @@ -1,45 +0,0 @@ ---- -automation: - id: "{{ config_entry["unique_id"] }} - {{ NAME }} - Update {{ rc_name }} - startup" - alias: "{{ NAME }} - Update {{ rc_name }} - startup" - description: "" - trigger: - - platform: homeassistant - event: start - - platform: event - event_type: rental_control_refresh - event_data: - rental_control_name: "{{ rc_name }}" - condition: [] - action: - - delay: - hours: 0 - minutes: 0 - seconds: 1 - milliseconds: 0 - - alias: Counted slot update - repeat: - count: '{{ config_entry["max_events"] }}' - sequence: - - service: rental_control.update_code_slot - data: - # yamllint disable rule:line-length - lockname: "{% raw %}{{{% endraw %} base {% raw %}}}{% endraw %}" - slot: "{% raw %}{{{% endraw %} start_slot|int + repeat.index|int {% raw %}}}{% endraw %}" - slot_name: >- - {% raw %}{{{% endraw %} states("input_text." + base + "_name_" + (start_slot|int + - repeat.index|int)|string) {% raw %}}}{% endraw %} - slot_code: >- - {% raw %}{{{% endraw %} states("input_text." + base + "_pin_" + (start_slot|int + - repeat.index|int)|string) {% raw %}}}{% endraw %} - start_time: >- - {% raw %}{{{% endraw %} states("input_datetime.start_date_" + base + "_" + - (start_slot|int + repeat.index|int)|string) {% raw %}}}{% endraw %} - end_time: >- - {% raw %}{{{% endraw %} states("input_datetime.end_date_" + base + "_" + - (start_slot|int + repeat.index|int)|string) {% raw %}}}{% endraw %} - # yamllint enable rule - variables: - start_slot: '{{ config_entry["start_slot"]|int - 1 }}' - base: '{{ config_entry["lockname"] }}' - mode: restart diff --git a/custom_components/rental_control/templates/update.yaml.j2 b/custom_components/rental_control/templates/update.yaml.j2 deleted file mode 100644 index 7a481a8..0000000 --- a/custom_components/rental_control/templates/update.yaml.j2 +++ /dev/null @@ -1,40 +0,0 @@ ---- -automation: - id: "{{ config_entry["unique_id"] }} - {{ NAME }} - Update {{ rc_name }}" - alias: "{{ NAME }} - Update {{ rc_name }}" - description: "" - trigger: - - platform: state - entity_id: - {% for n in range(0, config_entry["max_events"]|int) -%} - {%- set slot = config_entry["start_slot"]|int + n|int -%} - - input_text.{{ config_entry["lockname"] }}_name_{{ slot }} - - input_text.{{ config_entry["lockname"] }}_pin_{{ slot }} - - input_datetime.start_date_{{ config_entry["lockname"] }}_{{ slot }} - - input_datetime.end_date_{{ config_entry["lockname"] }}_{{ slot }} - {% endfor %} - variables: - ei: '{% raw %}{{ trigger.to_state.entity_id }}{% endraw %}' - et: '{% raw %}{{ ei.split(".")[0] }}{% endraw %}' - e: '{% raw %}{{ ei.split(".")[1] }}{% endraw %}' - sl: '{% raw %}{{ e.split("_")[-1] }}{% endraw %}' - base: >- - {% raw %}{%- if et == "input_text" -%} {{ e.split("_")[:-2] | join("_") }} {%-{% endraw %} - {% raw %}else -%} {{ e.split("_")[2:-1] | join("_") }} {%- endif -%}{% endraw %} - condition: [] - action: - - delay: - hours: 0 - minutes: 0 - seconds: 1 - milliseconds: 0 - - service: rental_control.update_code_slot - data: - lockname: '{% raw %}{{ base }}{% endraw %}' - slot: '{% raw %}{{ sl }}{% endraw %}' - slot_name: '{% raw %}{{ states("input_text." + base + "_name_" + sl|string) }}{% endraw %}' - slot_code: '{% raw %}{{ states("input_text." + base + "_pin_" + sl|string) }}{% endraw %}' - start_time: '{% raw %}{{ states("input_datetime.start_date_" + base + "_" + sl|string) }}{% endraw %}' - end_time: '{% raw %}{{ states("input_datetime.end_date_" + base + "_" + sl|string) }}{% endraw %}' - mode: queued - max: {{ config_entry["max_events"]|int + 2 }} diff --git a/custom_components/rental_control/util.py b/custom_components/rental_control/util.py index e76ce9c..4d45306 100644 --- a/custom_components/rental_control/util.py +++ b/custom_components/rental_control/util.py @@ -13,41 +13,67 @@ """Rental Control utils.""" from __future__ import annotations +import asyncio import hashlib import logging import os import re import uuid from typing import Any # noqa: F401 +from typing import Coroutine +from typing import Dict from typing import List from homeassistant.components.automation import DOMAIN as AUTO_DOMAIN +from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN +from homeassistant.components.input_datetime import DOMAIN as INPUT_DATETIME +from homeassistant.components.input_text import DOMAIN as INPUT_TEXT from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME +from homeassistant.const import EVENT_STATE_CHANGED from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotFound +from homeassistant.helpers.event import EventStateChangedData from homeassistant.util import dt from homeassistant.util import slugify -from jinja2 import Environment -from jinja2 import PackageLoader -from jinja2 import select_autoescape -from .const import ATTR_CODE_SLOT -from .const import ATTR_NAME -from .const import ATTR_SLOT_NAME from .const import CONF_PATH -from .const import EVENT_RENTAL_CONTROL_CLEAR_CODE -from .const import EVENT_RENTAL_CONTROL_SET_CODE +from .const import COORDINATOR +from .const import DEFAULT_PATH +from .const import DOMAIN from .const import NAME _LOGGER = logging.getLogger(__name__) +def add_call( + hass: HomeAssistant, + coro: List[Coroutine], + domain: str, + service: str, + target: str, + data: Dict[str, Any], +) -> List[Coroutine]: + """Append a new async_call to the coro list.""" + coro.append( + hass.services.async_call( + domain=domain, + service=service, + target={"entity_id": target}, + service_data=data, + blocking=True, + ) + ) + return coro + + def delete_rc_and_base_folder(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Delete packages folder for RC and base rental_control folder if empty.""" - base_path = os.path.join(hass.config.path(), config_entry.get(CONF_PATH)) - rc_name_slug = slugify(config_entry.get(CONF_NAME)) + base_path = os.path.join( + hass.config.path(), config_entry.data.get(CONF_PATH, DEFAULT_PATH) + ) + rc_name_slug = slugify(config_entry.data.get(CONF_NAME)) delete_folder(base_path, rc_name_slug) # It is possible that the path may not exist because of RCs not @@ -73,70 +99,160 @@ def delete_folder(absolute_path: str, *relative_paths: str) -> None: os.rmdir(path) -async def async_check_overrides(rc): - """Check if overrides need to have a clear_code event fired.""" +async def async_fire_clear_code(coordinator, slot: int) -> None: + """Fire a clear_code signal.""" + _LOGGER.debug(f"In async_fire_clear_code - slot: {slot}, name: {coordinator.name}") + hass = coordinator.hass + reset_entity = f"{INPUT_BOOLEAN}.reset_codeslot_{coordinator.lockname}_{slot}" - _LOGGER.debug("In async_check_overrides") + # Make sure that the reset is already off before sending a turn on event + await hass.services.async_call( + domain=INPUT_BOOLEAN, + service="turn_off", + target={"entity_id": reset_entity}, + blocking=True, + ) + await hass.services.async_call( + domain=INPUT_BOOLEAN, + service="turn_on", + target={"entity_id": reset_entity}, + blocking=True, + ) - event_list = rc.calendar - overrides = rc.event_overrides.copy() - event_names = get_event_names(rc) - _LOGGER.debug("event_names = '%s'", event_names) - _LOGGER.debug(overrides) +async def async_fire_set_code(coordinator, event, slot: int) -> None: + """Set codes into a slot.""" + _LOGGER.debug(f"In async_fire_set_code - slot: {slot}") - for override in overrides: - clear_code = False + lockname: str = coordinator.lockname + coro: List[Coroutine] = [] - if "Slot " not in override and override not in event_names: - _LOGGER.debug("%s is not in events, setting clear flag", override) - clear_code = True + # Disable the slot, this should help avoid notices from Keymaster about + # pin changes + coro = add_call( + coordinator.hass, + coro, + INPUT_BOOLEAN, + "turn_off", + f"input_boolean.enabled_{lockname}_{slot}", + {}, + ) + await asyncio.gather(*coro) + + coro.clear() + + # Load the slot data + coro = add_call( + coordinator.hass, + coro, + INPUT_DATETIME, + "set_datetime", + f"input_datetime.end_date_{lockname}_{slot}", + {"datetime": event.extra_state_attributes["end"]}, + ) - ovr = overrides[override] - _LOGGER.debug("Checking ovr = '%s'", ovr) + coro = add_call( + coordinator.hass, + coro, + INPUT_DATETIME, + "set_datetime", + f"input_datetime.start_date_{lockname}_{slot}", + {"datetime": event.extra_state_attributes["start"]}, + ) + + coro = add_call( + coordinator.hass, + coro, + INPUT_TEXT, + "set_value", + f"input_text.{lockname}_pin_{slot}", + {"value": event.extra_state_attributes["slot_code"]}, + ) - if ("slot_name" in ovr or "slot_code" in ovr) and ( - (ovr["end_time"].date() < dt.start_of_local_day().date()) - or ( - (event_list and rc.max_events <= len(event_list)) - and ( - ovr["start_time"].date() > event_list[rc.max_events - 1].end.date() - ) - ) - ): - _LOGGER.debug("%s is outside time options, setting clear flag", override) - clear_code = True + if coordinator.event_prefix: + prefix = f"{coordinator.event_prefix} " + else: + prefix = "" + + slot_name = f"{prefix}{event.extra_state_attributes['slot_name']}" + + coro = add_call( + coordinator.hass, + coro, + INPUT_TEXT, + "set_value", + f"input_text.{lockname}_name_{slot}", + {"value": slot_name}, + ) - if clear_code: - fire_clear_code(rc.hass, overrides[override]["slot"], rc.name) + coro = add_call( + coordinator.hass, + coro, + INPUT_BOOLEAN, + "turn_on", + f"input_boolean.daterange_{lockname}_{slot}", + {}, + ) + # Make sure the reset bool is turned off + coro = add_call( + coordinator.hass, + coro, + INPUT_BOOLEAN, + "turn_off", + f"input_boolean.reset_codeslot_{lockname}_{slot}", + {}, + ) -def fire_clear_code(hass: HomeAssistant, slot: int, name: str) -> None: - """Fire clear_code event.""" - _LOGGER.debug("In fire_clear_code - slot: %d, name: %s", slot, name) - hass.bus.fire( - EVENT_RENTAL_CONTROL_CLEAR_CODE, - event_data={ - ATTR_CODE_SLOT: slot, - ATTR_NAME: name, - }, + # Update the slot details + await asyncio.gather(*coro) + + # Turn on the slot + coro.clear() + coro = add_call( + coordinator.hass, + coro, + INPUT_BOOLEAN, + "turn_on", + f"input_boolean.enabled_{lockname}_{slot}", + {}, ) + await asyncio.gather(*coro) + + +async def async_fire_update_times(coordinator, event) -> None: + """Update times on slot.""" -def fire_set_code(hass: HomeAssistant, name: str, slot: int, slot_name: str) -> None: - """Fire set_code event.""" - _LOGGER.debug( - "In fire_set_code - name: %s, slot: %d, slot_name: %s", name, slot, slot_name + lockname: str = coordinator.lockname + coro: List[Coroutine] = [] + slot_name: str = event.extra_state_attributes["slot_name"] + slot = coordinator.event_overrides.get_slot_key_by_name(slot_name) + + if not slot: + return + + coro = add_call( + coordinator.hass, + coro, + INPUT_DATETIME, + "set_datetime", + f"input_datetime.end_date_{lockname}_{slot}", + {"datetime": event.extra_state_attributes["end"]}, ) - hass.bus.fire( - EVENT_RENTAL_CONTROL_SET_CODE, - event_data={ - ATTR_CODE_SLOT: slot, - ATTR_NAME: name, - ATTR_SLOT_NAME: slot_name, - }, + + coro = add_call( + coordinator.hass, + coro, + INPUT_DATETIME, + "set_datetime", + f"input_datetime.start_date_{lockname}_{slot}", + {"datetime": event.extra_state_attributes["start"]}, ) + # Update the slot details + await asyncio.gather(*coro) + def get_event_names(rc) -> List[str]: """Get the current event names.""" @@ -214,37 +330,42 @@ def get_slot_name(summary: str, description: str, prefix: str) -> str | None: return str(name).strip() -def write_template_config( - output_path: str, template_name: str, NAME: str, rc_name: str, config_entry +async def handle_state_change( + hass: HomeAssistant, + config_entry: ConfigEntry, + event: EventStateChangedData, ) -> None: - """Render the given template to disk.""" - _LOGGER.debug("In write_template_config") + """Listener to track state changes of Keymaster input entities.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + lockname = coordinator.lockname - jinja_env = Environment( - loader=PackageLoader("custom_components.rental_control"), - autoescape=select_autoescape(), - ) + if event.event_type != EVENT_STATE_CHANGED: + return - template = jinja_env.get_template(template_name + ".yaml.j2") - render = template.render( - NAME=NAME, - rc_name=rc_name, - config_entry=config_entry, - rc_slug=slugify(rc_name), - ) + # we can get state changed storms when a slot (or multiple slots) clear and + # a new code is set, put in a small sleep to let things settle + await asyncio.sleep(0.1) - _LOGGER.debug( - f"""Rendered Template is: - {render}""" - ) + entity_id = event.data["entity_id"] + + slot_num = int(entity_id.split("_")[-1]) - filename = slugify(f"{rc_name}_{template_name}") + ".yaml" + slot_code = hass.states.get(f"input_text.{lockname}_pin_{slot_num}") + slot_name = hass.states.get(f"input_text.{lockname}_name_{slot_num}") + start_time = hass.states.get(f"input_datetime.start_date_{lockname}_{slot_num}") + end_time = hass.states.get(f"input_datetime.end_date_{lockname}_{slot_num}") - with open(os.path.join(output_path, filename), "w+") as outfile: - _LOGGER.debug("Writing %s", filename) - outfile.write(render) + _LOGGER.debug(f"updating overrides for {lockname} slot {slot_num}") + await coordinator.update_event_overrides( + slot_num, + slot_code.as_dict()["state"], + slot_name.as_dict()["state"], + dt.parse_datetime(start_time.as_dict()["state"]), + dt.parse_datetime(end_time.as_dict()["state"]), + ) - _LOGGER.debug("Completed writing %s", filename) + # validate overrides + await coordinator.event_overrides.async_check_overrides(coordinator) async def async_reload_package_platforms(hass: HomeAssistant) -> bool: diff --git a/hacs.json b/hacs.json index 8d254de..7261a10 100644 --- a/hacs.json +++ b/hacs.json @@ -1,7 +1,7 @@ { "name": "Rental Control", - "hacs": "1.13.2", + "hacs": "1.32.1", "zip_release": true, "filename": "rental_control.zip", - "homeassistant": "2022.5.0" + "homeassistant": "2023.9.0" } diff --git a/info.md b/info.md index 919ccb6..af701ef 100644 --- a/info.md +++ b/info.md @@ -57,6 +57,51 @@ calendars and sensors to go with them related to managing rental properties. - Reservation url -- the URL to the reservation - Integration with [Keymaster](https://github.com/FutureTense/keymaster) to control door codes matched to the number of events being tracked +- Custom calendars are supported as long as they provide a valid ICS file via + an HTTPS connection. + - Events on the calendar can be done in multiple ways, but all events will + be treated as all day events (which is how all of the rental platforms + provide events). + - The event Summary (aka event title) _may_ contiain the word Reserved. + This will cause the slot name to be generated in one of two ways: + - The word Reserved is followed by ' - ' and then something else, the + something else will be used + - The word Reserved is _not_ followed by ' - ' then the full slot will + be used + - The Summary contains nothing else _and_ the Details contain + something that matches an Airbnb reservation identifier of + `[A-Z][A-Z0-9]{9}` that is a capital alphabet letter followed by 9 + more characters that are either capital alphabet letters or numbers, + then the slot will get this + - If the the Summary is _just_ Reserved and there is no Airbnb code in + the Description, then the event will be ignored for purposes of + managing a lock code. + - Technically any of the othe supported platform event styles for the + Summary can be used and as long as the Summary conforms to it. + - The best Summary on a manual calendar is to use your guest name. The + entries do need to be unique over the sensor count worth of events + or Rental Control will run into issues. + - Additional information can be provided in the Description of the event + and it will fill in the extra details in the sensor. + - Phone numbers for use in generating door codes can be provided in + one of two ways + - A line in the Description matching this regular expression: + `\(?Last 4 Digits\)?:\s+(\d{4})` -- This line will always take + precedence for generating a door code based on last 4 digits. + - A line in the Description matching this regular expression: + `Phone(?: Number)?:\s+(\+?[\d\. \-\(\)]{9,})` which will then + have the "air" squeezed out of it to extract the last 4 digits + in the number + - Number of guests + - A line in the Description that matches: `Guests:\s+(\d+)$` + - Alternatively, the following lines will be added together to get + the data: + - `Adults:\s+(\d+)$` + - `Children:\s+(\d+)$` + - Email addresses can be extracted from the Description by matching + against: `Email:\s+(\S+@\S+)` + - Reservation URLS will match against the first (and hopefully only) + URL in the Description ## Setup @@ -83,21 +128,11 @@ The integration is set up using the GUI. slot set correctly for the integration. - It is _very_ important that you have Keymaster fully working before - trying to utilize the slot management component of Rental Control. In - particular the `packages` directory configuration as Rental Control - generates automations using a similar mechanism to Keymaster. - - **NOTE:** It is very important that the Keymaster slots that you are - going to manage are either completely clear when you setup the - integration _or_ that they follow the following rules: - - - The slot name == Prefix(if defined) Slot_name(per the event sensor) - - The slot code == the defined slot code matches what is currently in - the event sensor - - The start and stop dates and times match what are in the sensor - - Failing to follow these rules may cause your configuration to behave in - unexpected way. - + trying to utilize the slot management component of Rental Control. + - **NOTE:** The Keymaster slots that are defined as being managed will be + completely taken control of by Rental Control. Any data in the slots + will be overwritten by Rental Control when it takes over the slot unless + it matches event data for the calendar. - The following portions of a Keymaster slot will influence (that is override) data in the calendar or event sensor: - Checkin/out TIME (not date) will update the calendar event and also @@ -112,9 +147,6 @@ The integration is set up using the GUI. slot that has the same door code (or starting code, typically first 4 digits) that is the generated code and thus causing the slot to not function properly - - An additional "mapping" sensor will be generated when setup to manage a - lock. This sensor is primarily used for fireing events for the generated - automations to pick up. ## Reconfiguration @@ -131,5 +163,7 @@ the `...` menu next to `Configure` and select `Reload` ## Known issues -While the integration supports reconfiguration a few things are not presently -working correctly with this. If you are needing to change +While the integration supports reconfiguration a few things may not fully update +after a reconfiguration. If you are having issues with reconfigured options +not being picked up properly try reloading the particular integration +installation or restart Home Assistant.