diff --git a/custom_components/o365/__init__.py b/custom_components/o365/__init__.py index 98d3d9e..7536481 100644 --- a/custom_components/o365/__init__.py +++ b/custom_components/o365/__init__.py @@ -158,7 +158,7 @@ async def _async_setup_account(hass, account_conf, conf_type): if is_authenticated and permissions and permissions != TOKEN_FILE_MISSING: check_token = await _async_check_token(hass, account, account_name) if check_token: - do_setup(hass, account_conf, account, account_name, conf_type, perms) + await do_setup(hass, account_conf, account, account_name, conf_type, perms) else: await _async_authorization_repair( hass, diff --git a/custom_components/o365/calendar.py b/custom_components/o365/calendar.py index 6b88f02..61b10f5 100644 --- a/custom_components/o365/calendar.py +++ b/custom_components/o365/calendar.py @@ -242,7 +242,6 @@ def _init_data(self, account, calendar_id, entity): max_results = entity.get(CONF_MAX_RESULTS) search = entity.get(CONF_SEARCH) exclude = entity.get(CONF_EXCLUDE) - # _LOGGER.debug("Initialising calendar: %s", calendar_id) return O365CalendarData( account, self.entity_id, diff --git a/custom_components/o365/classes/sensorentity.py b/custom_components/o365/classes/sensorentity.py index f219649..84602af 100644 --- a/custom_components/o365/classes/sensorentity.py +++ b/custom_components/o365/classes/sensorentity.py @@ -27,7 +27,7 @@ def name(self): @property def entity_key(self): - """Entity Keyr property.""" + """Entity Key property.""" return self._entity_id @property diff --git a/custom_components/o365/classes/taskssensor.py b/custom_components/o365/classes/taskssensor.py index f93bab6..9f2f8f9 100644 --- a/custom_components/o365/classes/taskssensor.py +++ b/custom_components/o365/classes/taskssensor.py @@ -1,14 +1,16 @@ """O365 tasks sensors.""" import logging -from datetime import timedelta +from datetime import datetime, timedelta import voluptuous as vol from homeassistant.components.sensor import SensorEntity +from homeassistant.const import CONF_ENABLED from homeassistant.util import dt from ..const import ( ATTR_ALL_TASKS, ATTR_COMPLETED, + ATTR_CREATED, ATTR_DESCRIPTION, ATTR_DUE, ATTR_OVERDUE_TASKS, @@ -16,9 +18,10 @@ ATTR_SUBJECT, ATTR_TASK_ID, ATTR_TASKS, - CONF_DUE_HOURS_BACKWARD_TO_GET, - CONF_DUE_HOURS_FORWARD_TO_GET, + CONF_ACCOUNT, CONF_SHOW_COMPLETED, + CONF_TODO_SENSORS, + CONF_TRACK_NEW, DATETIME_FORMAT, DOMAIN, EVENT_COMPLETED_TASK, @@ -31,6 +34,7 @@ PERM_TASKS_READWRITE, SENSOR_TODO, ) +from ..utils.filemgmt import update_task_list_file from .sensorentity import O365Sensor _LOGGER = logging.getLogger(__name__) @@ -44,14 +48,10 @@ def __init__(self, coordinator, todo, name, task, config, entity_id, unique_id): super().__init__(coordinator, config, name, entity_id, SENSOR_TODO, unique_id) self.todo = todo self._show_completed = task.get(CONF_SHOW_COMPLETED) - self.query = self.todo.new_query() - if not self._show_completed: - self.query = self.query.on_attribute("status").unequal("completed") - self.start_offset = task.get(CONF_DUE_HOURS_BACKWARD_TO_GET) - self.end_offset = task.get(CONF_DUE_HOURS_FORWARD_TO_GET) self.task_last_created = dt.utcnow() - timedelta(minutes=5) self.task_last_completed = dt.utcnow() - timedelta(minutes=5) + self._zero_date = datetime(1, 1, 1, 0, 0, 0, tzinfo=dt.DEFAULT_TIME_ZONE) @property def icon(self): @@ -96,6 +96,34 @@ def extra_state_attributes(self): extra_attributes[ATTR_OVERDUE_TASKS] = overdue_tasks return extra_attributes + def _handle_coordinator_update(self) -> None: + tasks = self.coordinator.data[self.entity_key][ATTR_TASKS] + task_last_completed = self._zero_date + task_last_created = self._zero_date + for task in tasks: + if task.completed and task.completed > self.task_last_completed: + self._raise_event_external( + EVENT_COMPLETED_TASK, + task.task_id, + ATTR_COMPLETED, + task.completed, + ) + if task.completed > task_last_completed: + task_last_completed = task.completed + if task.created and task.created > self.task_last_created: + self._raise_event_external( + EVENT_NEW_TASK, task.task_id, ATTR_CREATED, task.created + ) + if task.created > task_last_created: + task_last_created = task.created + + if task_last_completed > self._zero_date: + self.task_last_completed = task_last_completed + if task_last_created > self._zero_date: + self.task_last_created = task_last_created + + self.async_write_ha_state() + def new_task(self, subject, description=None, due=None, reminder=None): """Create a new task for this task list.""" if not self._validate_task_permissions(): @@ -186,8 +214,41 @@ def _raise_event(self, event_type, task_id): ) _LOGGER.debug("%s - %s", event_type, task_id) + def _raise_event_external(self, event_type, task_id, time_type, task_datetime): + self.hass.bus.fire( + f"{DOMAIN}_{event_type}", + {ATTR_TASK_ID: task_id, time_type: task_datetime, EVENT_HA_EVENT: False}, + ) + _LOGGER.debug("%s - %s - %s", event_type, task_id, task_datetime) + def _validate_task_permissions(self): return self._validate_permissions( PERM_MINIMUM_TASKS_WRITE, f"Not authorised to create new task - requires permission: {PERM_TASKS_READWRITE}", ) + + +class O365TasksSensorSensorServices: + """Sensor Services.""" + + def __init__(self, hass): + """Initialise the sensor services.""" + self._hass = hass + + async def async_scan_for_task_lists(self, call): # pylint: disable=unused-argument + """Scan for new task lists.""" + for config in self._hass.data[DOMAIN]: + config = self._hass.data[DOMAIN][config] + todo_sensor = config.get(CONF_TODO_SENSORS) + if todo_sensor and CONF_ACCOUNT in config and todo_sensor.get(CONF_ENABLED): + todos = config[CONF_ACCOUNT].tasks() + + todolists = await self._hass.async_add_executor_job(todos.list_folders) + track = todo_sensor.get(CONF_TRACK_NEW) + for todo in todolists: + update_task_list_file( + config, + todo, + self._hass, + track, + ) diff --git a/custom_components/o365/const.py b/custom_components/o365/const.py index 1617e4f..aa5961a 100644 --- a/custom_components/o365/const.py +++ b/custom_components/o365/const.py @@ -82,6 +82,7 @@ class EventResponse(Enum): CONF_CLIENT_ID = "client_id" CONF_CLIENT_SECRET = "client_secret" # nosec CONF_CONFIG_TYPE = "config_type" +CONF_COORDINATOR = "coordinator" CONF_DEVICE_ID = "device_id" CONF_DOWNLOAD_ATTACHMENTS = "download_attachments" CONF_DUE_HOURS_BACKWARD_TO_GET = "due_start_offset" @@ -89,6 +90,8 @@ class EventResponse(Enum): CONF_EMAIL_SENSORS = "email_sensor" CONF_ENABLE_UPDATE = "enable_update" CONF_ENTITIES = "entities" +CONF_ENTITY_KEY = "entity_key" +CONF_ENTITY_TYPE = "entity_type" CONF_EXCLUDE = "exclude" CONF_FAILED_PERMISSIONS = "failed_permissions" CONF_GROUPS = "groups" @@ -98,6 +101,7 @@ class EventResponse(Enum): CONF_HTML_BODY = "html_body" CONF_IMPORTANCE = "importance" CONF_IS_UNREAD = "is_unread" +CONF_KEYS = "keys" CONF_MAIL_FOLDER = "folder" CONF_MAIL_FROM = "from" CONF_MAX_ITEMS = "max_items" @@ -110,11 +114,13 @@ class EventResponse(Enum): CONF_STATUS_SENSORS = "status_sensors" CONF_SUBJECT_CONTAINS = "subject_contains" CONF_SUBJECT_IS = "subject_is" +CONF_TODO = "todo" CONF_TODO_SENSORS = "todo_sensors" CONF_TRACK = "track" CONF_TRACK_NEW_CALENDAR = "track_new_calendar" CONF_TRACK_NEW = "track_new" CONF_TASK_LIST_ID = "task_list_id" +CONF_TASK_LIST = "task_list" CONF_URL = "url" CONST_CONFIG_TYPE_DICT = "dict" CONST_CONFIG_TYPE_LIST = "list" diff --git a/custom_components/o365/coordinator.py b/custom_components/o365/coordinator.py new file mode 100644 index 0000000..44dcde3 --- /dev/null +++ b/custom_components/o365/coordinator.py @@ -0,0 +1,499 @@ +"""Sensor processing.""" +import functools as ft +import logging +from datetime import datetime, timedelta +from operator import itemgetter + +from homeassistant.const import CONF_ENABLED, CONF_NAME, CONF_UNIQUE_ID +from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt +from requests.exceptions import HTTPError + +from .classes.mailsensor import O365AutoReplySensor, O365EmailSensor, O365QuerySensor +from .classes.taskssensor import O365TasksSensorSensorServices +from .classes.teamssensor import O365TeamsChatSensor, O365TeamsStatusSensor +from .const import ( + ATTR_ATTRIBUTES, + ATTR_AUTOREPLIESSETTINGS, + ATTR_CHAT_ID, + ATTR_CHAT_TYPE, + ATTR_CONTENT, + ATTR_DATA, + ATTR_ERROR, + ATTR_FROM_DISPLAY_NAME, + ATTR_IMPORTANCE, + ATTR_MEMBERS, + ATTR_STATE, + ATTR_SUBJECT, + ATTR_SUMMARY, + ATTR_TASK_ID, + ATTR_TASKS, + ATTR_TOPIC, + CONF_ACCOUNT, + CONF_ACCOUNT_NAME, + CONF_AUTO_REPLY_SENSORS, + CONF_CHAT_SENSORS, + CONF_DUE_HOURS_BACKWARD_TO_GET, + CONF_DUE_HOURS_FORWARD_TO_GET, + CONF_EMAIL_SENSORS, + CONF_ENABLE_UPDATE, + CONF_ENTITY_KEY, + CONF_ENTITY_TYPE, + CONF_MAIL_FOLDER, + CONF_QUERY_SENSORS, + CONF_STATUS_SENSORS, + CONF_TASK_LIST, + CONF_TASK_LIST_ID, + CONF_TODO, + CONF_TODO_SENSORS, + CONF_TRACK, + DOMAIN, + EVENT_HA_EVENT, + LEGACY_ACCOUNT_NAME, + SENSOR_AUTO_REPLY, + SENSOR_ENTITY_ID_FORMAT, + SENSOR_MAIL, + SENSOR_TEAMS_CHAT, + SENSOR_TEAMS_STATUS, + SENSOR_TODO, + YAML_TASK_LISTS, +) +from .schema import TASK_LIST_SCHEMA +from .utils.filemgmt import build_config_file_path, build_yaml_filename, load_yaml_file +from .utils.utils import get_email_attributes + +_LOGGER = logging.getLogger(__name__) + + +class O365SensorCordinator(DataUpdateCoordinator): + """O365 sensor data update coordinator.""" + + def __init__(self, hass, config): + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name="O365 Sensors", + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta(seconds=30), + ) + self._config = config + self._account = config[CONF_ACCOUNT] + self._account_name = config[CONF_ACCOUNT_NAME] + self._entities = [] + self._keys = [] + self._data = {} + self._zero_date = datetime(1, 1, 1, 0, 0, 0, tzinfo=dt.DEFAULT_TIME_ZONE) + self._chat_members = {} + + async def async_setup_entries(self): + """Do the initial setup of the entities.""" + email_entities = await self._async_email_sensors() + query_entities = await self._async_query_sensors() + status_entities = self._status_sensors() + chat_entities = self._chat_sensors() + todo_entities, todo_keys = await self._async_todo_sensors() + auto_reply_entities = await self._async_auto_reply_sensors() + self._entities = ( + email_entities + + query_entities + + status_entities + + chat_entities + + todo_entities + + auto_reply_entities + ) + self._keys = todo_keys + return self._entities, self._keys + + async def _async_email_sensors(self): + email_sensors = self._config.get(CONF_EMAIL_SENSORS, []) + entities = [] + _LOGGER.debug("Email sensor setup: %s ", self._account_name) + for sensor_conf in email_sensors: + name = sensor_conf[CONF_NAME] + _LOGGER.debug( + "Email sensor setup: %s, %s", + self._account_name, + name, + ) + if mail_folder := await self._async_get_mail_folder( + sensor_conf, CONF_EMAIL_SENSORS + ): + entity_id = self._build_entity_id(name) + unique_id = f"{mail_folder.folder_id}_{self._account_name}" + emailsensor = O365EmailSensor( + self, + self._config, + sensor_conf, + mail_folder, + name, + entity_id, + unique_id, + ) + _LOGGER.debug( + "Email sensor added: %s, %s", + self._account_name, + name, + ) + entities.append(emailsensor) + return entities + + async def _async_query_sensors(self): + query_sensors = self._config.get(CONF_QUERY_SENSORS, []) + entities = [] + for sensor_conf in query_sensors: + if mail_folder := await self._async_get_mail_folder( + sensor_conf, CONF_QUERY_SENSORS + ): + name = sensor_conf.get(CONF_NAME) + entity_id = self._build_entity_id(name) + unique_id = f"{mail_folder.folder_id}_{self._account_name}" + querysensor = O365QuerySensor( + self, + self._config, + sensor_conf, + mail_folder, + name, + entity_id, + unique_id, + ) + entities.append(querysensor) + return entities + + def _status_sensors(self): + status_sensors = self._config.get(CONF_STATUS_SENSORS, []) + entities = [] + for sensor_conf in status_sensors: + name = sensor_conf.get(CONF_NAME) + entity_id = self._build_entity_id(name) + unique_id = f"{name}_{self._account_name}" + teams_status_sensor = O365TeamsStatusSensor( + self, self._account, name, entity_id, self._config, unique_id + ) + entities.append(teams_status_sensor) + return entities + + def _chat_sensors(self): + chat_sensors = self._config.get(CONF_CHAT_SENSORS, []) + entities = [] + for sensor_conf in chat_sensors: + name = sensor_conf.get(CONF_NAME) + enable_update = sensor_conf.get(CONF_ENABLE_UPDATE) + entity_id = self._build_entity_id(name) + unique_id = f"{name}_{self._account_name}" + teams_chat_sensor = O365TeamsChatSensor( + self, + self._account, + name, + entity_id, + self._config, + unique_id, + enable_update, + ) + entities.append(teams_chat_sensor) + return entities + + async def _async_todo_sensors(self): + todo_sensors = self._config.get(CONF_TODO_SENSORS) + entities = [] + keys = [] + if todo_sensors and todo_sensors.get(CONF_ENABLED): + sensor_services = O365TasksSensorSensorServices(self.hass) + await sensor_services.async_scan_for_task_lists(None) + + yaml_filename = build_yaml_filename(self._config, YAML_TASK_LISTS) + yaml_filepath = build_config_file_path(self.hass, yaml_filename) + task_dict = load_yaml_file( + yaml_filepath, CONF_TASK_LIST_ID, TASK_LIST_SCHEMA + ) + task_lists = list(task_dict.values()) + entities, keys = await self._async_todo_entities(task_lists) + + return entities, keys + + async def _async_todo_entities(self, task_lists): + entities = [] + keys = [] + tasks = self._account.tasks() + for tasklist in task_lists: + track = tasklist.get(CONF_TRACK) + if not track: + continue + + task_list_id = tasklist.get(CONF_TASK_LIST_ID) + if self._account_name != LEGACY_ACCOUNT_NAME: + name = f"{tasklist.get(CONF_NAME)} {self._account_name}" + else: + name = tasklist.get(CONF_NAME) + try: + todo = ( + await self.hass.async_add_executor_job( # pylint: disable=no-member + ft.partial( + tasks.get_folder, + folder_id=task_list_id, + ) + ) + ) + entity_id = self._build_entity_id(name) + unique_id = f"{task_list_id}_{self._account_name}" + + new_key = { + CONF_ENTITY_KEY: entity_id, + CONF_UNIQUE_ID: unique_id, + CONF_TODO: todo, + CONF_NAME: name, + CONF_TASK_LIST: tasklist, + CONF_ENTITY_TYPE: SENSOR_TODO, + } + + keys.append(new_key) + except HTTPError: + _LOGGER.warning( + "Task list not found for: %s - Please remove from O365_tasks_%s.yaml", + name, + self._account_name, + ) + return entities, keys + + async def _async_auto_reply_sensors(self): + auto_reply_sensors = self._config.get(CONF_AUTO_REPLY_SENSORS, []) + entities = [] + for sensor_conf in auto_reply_sensors: + name = sensor_conf.get(CONF_NAME) + entity_id = self._build_entity_id(name) + unique_id = f"{name}_{self._account_name}" + auto_reply_sensor = O365AutoReplySensor( + self, name, entity_id, self._config, unique_id + ) + entities.append(auto_reply_sensor) + return entities + + async def _async_get_mail_folder(self, sensor_conf, sensor_type): + """Get the configured folder.""" + mailbox = self._account.mailbox() + _LOGGER.debug("Get mail folder: %s", sensor_conf.get(CONF_NAME)) + if mail_folder_conf := sensor_conf.get(CONF_MAIL_FOLDER): + return await self._async_get_configured_mail_folder( + mail_folder_conf, mailbox, sensor_type + ) + + return mailbox.inbox_folder() + + async def _async_get_configured_mail_folder( + self, mail_folder_conf, mailbox, sensor_type + ): + mail_folder = mailbox + for folder in mail_folder_conf.split("/"): + mail_folder = await self.hass.async_add_executor_job( + ft.partial( + mail_folder.get_folder, + folder_name=folder, + ) + ) + if not mail_folder: + _LOGGER.error( + "Folder - %s - not found from %s config entry - %s - entity not created", + folder, + sensor_type, + mail_folder_conf, + ) + return None + + return mail_folder + + async def _async_update_data(self): + _LOGGER.debug("Doing sensor update for: %s", self._account_name) + for entity in self._entities: + if entity.entity_type == SENSOR_MAIL: + await self._async_email_update(entity) + elif entity.entity_type == SENSOR_TEAMS_STATUS: + await self._async_teams_status_update(entity) + elif entity.entity_type == SENSOR_TEAMS_CHAT: + await self._async_teams_chat_update(entity) + elif entity.entity_type == SENSOR_TODO: + await self._async_todos_update(entity) + elif entity.entity_type == SENSOR_AUTO_REPLY: + await self._async_auto_reply_update(entity) + + for key in self._keys: + if key[CONF_ENTITY_TYPE] == SENSOR_TODO: + await self._async_todos_update(key) + + return self._data + + async def _async_email_update(self, entity): + """Update code.""" + data = await self.hass.async_add_executor_job( # pylint: disable=no-member + ft.partial( + entity.mail_folder.get_messages, + limit=entity.max_items, + query=entity.query, + download_attachments=entity.download_attachments, + ) + ) + attrs = await self.hass.async_add_executor_job( # pylint: disable=no-member + self._get_attributes, data, entity + ) + attrs.sort(key=itemgetter("received"), reverse=True) + self._data[entity.entity_key] = { + ATTR_STATE: len(attrs), + ATTR_ATTRIBUTES: {ATTR_DATA: attrs}, + } + + def _get_attributes(self, data, entity): + return [ + get_email_attributes(x, entity.download_attachments, entity.html_body) + for x in data + ] + + async def _async_teams_status_update(self, entity): + """Update state.""" + if data := await self.hass.async_add_executor_job(entity.teams.get_my_presence): + self._data[entity.entity_key] = {ATTR_STATE: data.activity} + + async def _async_teams_chat_update(self, entity): + """Update state.""" + state = None + data = [] + self._data[entity.entity_key] = {} + extra_attributes = {} + chats = await self.hass.async_add_executor_job( + ft.partial(entity.teams.get_my_chats, limit=20) + ) + for chat in chats: + if chat.chat_type == "unknownFutureValue": + continue + if not state: + messages = await self.hass.async_add_executor_job( + ft.partial(chat.get_messages, limit=10) + ) + state, extra_attributes = self._process_chat_messages(messages) + + if not entity.enable_update: + if state: + break + continue + + memberlist = await self._async_get_memberlist(chat) + chatitems = { + ATTR_CHAT_ID: chat.object_id, + ATTR_CHAT_TYPE: chat.chat_type, + ATTR_MEMBERS: ",".join(memberlist), + } + if chat.chat_type == "group": + chatitems[ATTR_TOPIC] = chat.topic + + data.append(chatitems) + + self._data[entity.entity_key] = ( + {ATTR_STATE: state} | extra_attributes | {ATTR_DATA: data} + ) + + def _process_chat_messages(self, messages): + state = None + extra_attributes = {} + for message in messages: + if not state and message.content != "": + state = message.created_date + extra_attributes = { + ATTR_FROM_DISPLAY_NAME: message.from_display_name, + ATTR_CONTENT: message.content, + ATTR_CHAT_ID: message.chat_id, + ATTR_IMPORTANCE: message.importance, + ATTR_SUBJECT: message.subject, + ATTR_SUMMARY: message.summary, + } + break + return state, extra_attributes + + async def _async_get_memberlist(self, chat): + if chat.object_id in self._chat_members and chat.chat_type != "oneOnOne": + return self._chat_members[chat.object_id] + members = await self.hass.async_add_executor_job(chat.get_members) + memberlist = [member.display_name for member in members] + self._chat_members[chat.object_id] = memberlist + return memberlist + + async def _async_todos_update(self, key): + """Update state.""" + entity_key = key["entity_key"] + if entity_key in self._data: + error = self._data[entity_key][ATTR_ERROR] + else: + self._data[entity_key] = {ATTR_TASKS: {}, ATTR_STATE: 0} + error = False + data, error = await self._async_todos_update_query(key, error) + if not error: + tasks = list(data) + self._data[entity_key][ATTR_TASKS] = tasks + self._data[entity_key][ATTR_STATE] = len(tasks) + + self._data[entity_key][ATTR_ERROR] = error + + async def _async_todos_update_query(self, key, error): + data = None + todo = key[CONF_TODO] + full_query = self._create_todo_query(key, todo) + name = key[CONF_NAME] + + try: + data = await self.hass.async_add_executor_job( # pylint: disable=no-member + ft.partial(todo.get_tasks, batch=100, query=full_query) + ) + if error: + _LOGGER.info("Task list reconnected for: %s", name) + error = False + except HTTPError: + if not error: + _LOGGER.error( + "Task list not found for: %s - Has it been deleted?", + name, + ) + error = True + + return data, error + + def _create_todo_query(self, key, todo): + task = key[CONF_TASK_LIST] + show_completed = task["show_completed"] + query = todo.new_query() + if not show_completed: + query = query.on_attribute("status").unequal("completed") + start_offset = task.get(CONF_DUE_HOURS_BACKWARD_TO_GET) + end_offset = task.get(CONF_DUE_HOURS_FORWARD_TO_GET) + if start_offset: + start = dt.utcnow() + timedelta(hours=start_offset) + query.chain("and").on_attribute("due").greater_equal( + start.strftime("%Y-%m-%dT%H:%M:%S") + ) + if end_offset: + end = dt.utcnow() + timedelta(hours=end_offset) + query.chain("and").on_attribute("due").less_equal( + end.strftime("%Y-%m-%dT%H:%M:%S") + ) + return query + + async def _async_auto_reply_update(self, entity): + """Update state.""" + if data := await self.hass.async_add_executor_job(entity.mailbox.get_settings): + self._data[entity.entity_key] = { + ATTR_STATE: data.automaticrepliessettings.status.value, + ATTR_AUTOREPLIESSETTINGS: data.automaticrepliessettings, + } + + def _build_entity_id(self, name): + """Build and entity ID.""" + return async_generate_entity_id( + SENSOR_ENTITY_ID_FORMAT, + name, + hass=self.hass, + ) + + def _raise_event(self, event_type, task_id, time_type, task_datetime): + self.hass.bus.fire( + f"{DOMAIN}_{event_type}", + {ATTR_TASK_ID: task_id, time_type: task_datetime, EVENT_HA_EVENT: False}, + ) + _LOGGER.debug("%s - %s - %s", event_type, task_id, task_datetime) diff --git a/custom_components/o365/repairs.py b/custom_components/o365/repairs.py index ae595f4..5f65d1b 100644 --- a/custom_components/o365/repairs.py +++ b/custom_components/o365/repairs.py @@ -153,7 +153,7 @@ async def _async_validate_response(self, user_input): if not permissions: errors[CONF_URL] = "minimum_permissions" - do_setup( + await do_setup( self.hass, self._conf, self._account, diff --git a/custom_components/o365/sensor.py b/custom_components/o365/sensor.py index cbdb661..53ce9ba 100644 --- a/custom_components/o365/sensor.py +++ b/custom_components/o365/sensor.py @@ -1,86 +1,43 @@ """Sensor processing.""" -import functools as ft + import logging -from copy import deepcopy -from datetime import datetime, timedelta -from operator import itemgetter -from homeassistant.const import CONF_ENABLED, CONF_NAME +from homeassistant.const import CONF_ENABLED, CONF_NAME, CONF_UNIQUE_ID from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.util import dt -from requests.exceptions import HTTPError -from .classes.mailsensor import O365AutoReplySensor, O365EmailSensor, O365QuerySensor -from .classes.taskssensor import O365TasksSensor -from .classes.teamssensor import O365TeamsChatSensor, O365TeamsStatusSensor +from .classes.taskssensor import O365TasksSensor, O365TasksSensorSensorServices +from .const import CONF_ACCOUNT_NAME # SENSOR_TODO, from .const import ( - ATTR_ATTRIBUTES, - ATTR_AUTOREPLIESSETTINGS, - ATTR_CHAT_ID, - ATTR_CHAT_TYPE, - ATTR_COMPLETED, - ATTR_CONTENT, - ATTR_CREATED, - ATTR_DATA, - ATTR_ERROR, - ATTR_FROM_DISPLAY_NAME, - ATTR_IMPORTANCE, - ATTR_MEMBERS, - ATTR_STATE, - ATTR_SUBJECT, - ATTR_SUMMARY, - ATTR_TASK_ID, - ATTR_TASKS, - ATTR_TOPIC, CONF_ACCOUNT, - CONF_ACCOUNT_NAME, CONF_AUTO_REPLY_SENSORS, CONF_CHAT_SENSORS, - CONF_EMAIL_SENSORS, + CONF_COORDINATOR, CONF_ENABLE_UPDATE, - CONF_MAIL_FOLDER, + CONF_ENTITIES, + CONF_ENTITY_KEY, + CONF_KEYS, CONF_PERMISSIONS, - CONF_QUERY_SENSORS, - CONF_STATUS_SENSORS, - CONF_TASK_LIST_ID, + CONF_TASK_LIST, + CONF_TODO, CONF_TODO_SENSORS, - CONF_TRACK, - CONF_TRACK_NEW, DOMAIN, - EVENT_COMPLETED_TASK, - EVENT_HA_EVENT, - EVENT_NEW_TASK, - LEGACY_ACCOUNT_NAME, PERM_MINIMUM_CHAT_WRITE, PERM_MINIMUM_MAILBOX_SETTINGS, PERM_MINIMUM_TASKS_WRITE, SENSOR_AUTO_REPLY, - SENSOR_ENTITY_ID_FORMAT, SENSOR_MAIL, SENSOR_TEAMS_CHAT, SENSOR_TEAMS_STATUS, - SENSOR_TODO, - YAML_TASK_LISTS, ) from .schema import ( AUTO_REPLY_SERVICE_DISABLE_SCHEMA, AUTO_REPLY_SERVICE_ENABLE_SCHEMA, CHAT_SERVICE_SEND_MESSAGE_SCHEMA, - TASK_LIST_SCHEMA, TASK_SERVICE_COMPLETE_SCHEMA, TASK_SERVICE_DELETE_SCHEMA, TASK_SERVICE_NEW_SCHEMA, TASK_SERVICE_UPDATE_SCHEMA, ) -from .utils.filemgmt import ( - build_config_file_path, - build_yaml_filename, - load_yaml_file, - update_task_list_file, -) -from .utils.utils import get_email_attributes _LOGGER = logging.getLogger(__name__) @@ -100,441 +57,36 @@ async def async_setup_platform( if not is_authenticated: return False - coordinator = O365SensorCordinator(hass, conf) - entities = await coordinator.async_setup_entries() - await coordinator.async_config_entry_first_refresh() - async_add_entities(entities, False) - await _async_setup_register_services(hass, conf) - - return True - - -class O365SensorCordinator(DataUpdateCoordinator): - """O365 sensor data update coordinator.""" - - def __init__(self, hass, config): - """Initialize my coordinator.""" - super().__init__( - hass, - _LOGGER, - # Name of the data. For logging purposes. - name="O365 Sensors", - # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=30), - ) - self._config = config - self._account = config[CONF_ACCOUNT] - self._account_name = config[CONF_ACCOUNT_NAME] - self._entities = [] - self._data = {} - self._zero_date = datetime(1, 1, 1, 0, 0, 0, tzinfo=dt.DEFAULT_TIME_ZONE) - self._chat_members = {} - - async def async_setup_entries(self): - """Do the initial setup of the entities.""" - email_entities = await self._async_email_sensors() - query_entities = await self._async_query_sensors() - status_entities = self._status_sensors() - chat_entities = self._chat_sensors() - todo_entities = await self._async_todo_sensors() - auto_reply_entities = await self._async_auto_reply_sensors() - self._entities = ( - email_entities - + query_entities - + status_entities - + chat_entities - + todo_entities - + auto_reply_entities - ) - return self._entities - - async def _async_email_sensors(self): - email_sensors = self._config.get(CONF_EMAIL_SENSORS, []) - entities = [] - _LOGGER.debug("Email sensor setup: %s ", self._account_name) - for sensor_conf in email_sensors: - name = sensor_conf[CONF_NAME] - _LOGGER.debug( - "Email sensor setup: %s, %s", - self._account_name, - name, - ) - if mail_folder := await self._async_get_mail_folder( - sensor_conf, CONF_EMAIL_SENSORS - ): - entity_id = self._build_entity_id(name) - unique_id = f"{mail_folder.folder_id}_{self._account_name}" - emailsensor = O365EmailSensor( - self, - self._config, - sensor_conf, - mail_folder, - name, - entity_id, - unique_id, - ) - _LOGGER.debug( - "Email sensor added: %s, %s", - self._account_name, - name, - ) - entities.append(emailsensor) - return entities - - async def _async_query_sensors(self): - query_sensors = self._config.get(CONF_QUERY_SENSORS, []) - entities = [] - for sensor_conf in query_sensors: - if mail_folder := await self._async_get_mail_folder( - sensor_conf, CONF_QUERY_SENSORS - ): - name = sensor_conf.get(CONF_NAME) - entity_id = self._build_entity_id(name) - unique_id = f"{mail_folder.folder_id}_{self._account_name}" - querysensor = O365QuerySensor( - self, - self._config, - sensor_conf, - mail_folder, - name, - entity_id, - unique_id, - ) - entities.append(querysensor) - return entities - - def _status_sensors(self): - status_sensors = self._config.get(CONF_STATUS_SENSORS, []) - entities = [] - for sensor_conf in status_sensors: - name = sensor_conf.get(CONF_NAME) - entity_id = self._build_entity_id(name) - unique_id = f"{name}_{self._account_name}" - teams_status_sensor = O365TeamsStatusSensor( - self, self._account, name, entity_id, self._config, unique_id - ) - entities.append(teams_status_sensor) - return entities - - def _chat_sensors(self): - chat_sensors = self._config.get(CONF_CHAT_SENSORS, []) - entities = [] - for sensor_conf in chat_sensors: - name = sensor_conf.get(CONF_NAME) - enable_update = sensor_conf.get(CONF_ENABLE_UPDATE) - entity_id = self._build_entity_id(name) - unique_id = f"{name}_{self._account_name}" - teams_chat_sensor = O365TeamsChatSensor( - self, - self._account, - name, - entity_id, - self._config, - unique_id, - enable_update, - ) - entities.append(teams_chat_sensor) - return entities - - async def _async_todo_sensors(self): - todo_sensors = self._config.get(CONF_TODO_SENSORS) - entities = [] - if todo_sensors and todo_sensors.get(CONF_ENABLED): - sensor_services = SensorServices(self.hass) - await sensor_services.async_scan_for_task_lists(None) - - yaml_filename = build_yaml_filename(self._config, YAML_TASK_LISTS) - yaml_filepath = build_config_file_path(self.hass, yaml_filename) - task_dict = load_yaml_file( - yaml_filepath, CONF_TASK_LIST_ID, TASK_LIST_SCHEMA - ) - task_lists = list(task_dict.values()) - entities = await self._async_todo_entities(task_lists, self._config) - - return entities - - async def _async_todo_entities(self, task_lists, config): - entities = [] - tasks = self._account.tasks() - for task in task_lists: - track = task.get(CONF_TRACK) - if not track: - continue - - task_list_id = task.get(CONF_TASK_LIST_ID) - if self._account_name != LEGACY_ACCOUNT_NAME: - name = f"{task.get(CONF_NAME)} {self._account_name}" - else: - name = task.get(CONF_NAME) - try: - todo = ( - await self.hass.async_add_executor_job( # pylint: disable=no-member - ft.partial( - tasks.get_folder, - folder_id=task_list_id, - ) - ) - ) - entity_id = self._build_entity_id(name) - unique_id = f"{task_list_id}_{self._account_name}" - todo_sensor = O365TasksSensor( - self, todo, name, task, config, entity_id, unique_id - ) - entities.append(todo_sensor) - except HTTPError: - _LOGGER.warning( - "Task list not found for: %s - Please remove from O365_tasks_%s.yaml", - name, - self._account_name, - ) - return entities - - async def _async_auto_reply_sensors(self): - auto_reply_sensors = self._config.get(CONF_AUTO_REPLY_SENSORS, []) - entities = [] - for sensor_conf in auto_reply_sensors: - name = sensor_conf.get(CONF_NAME) - entity_id = self._build_entity_id(name) - unique_id = f"{name}_{self._account_name}" - auto_reply_sensor = O365AutoReplySensor( - self, name, entity_id, self._config, unique_id - ) - entities.append(auto_reply_sensor) - return entities - - async def _async_get_mail_folder(self, sensor_conf, sensor_type): - """Get the configured folder.""" - mailbox = self._account.mailbox() - _LOGGER.debug("Get mail folder: %s", sensor_conf.get(CONF_NAME)) - if mail_folder_conf := sensor_conf.get(CONF_MAIL_FOLDER): - return await self._async_get_configured_mail_folder( - mail_folder_conf, mailbox, sensor_type - ) - - return mailbox.inbox_folder() - - async def _async_get_configured_mail_folder( - self, mail_folder_conf, mailbox, sensor_type - ): - mail_folder = mailbox - for folder in mail_folder_conf.split("/"): - mail_folder = await self.hass.async_add_executor_job( - ft.partial( - mail_folder.get_folder, - folder_name=folder, - ) - ) - if not mail_folder: - _LOGGER.error( - "Folder - %s - not found from %s config entry - %s - entity not created", - folder, - sensor_type, - mail_folder_conf, - ) - return None - - return mail_folder - - async def _async_update_data(self): - _LOGGER.debug("Doing sensor update for: %s", self._account_name) - for entity in self._entities: - if entity.entity_type == SENSOR_MAIL: - await self._async_email_update(entity) - elif entity.entity_type == SENSOR_TEAMS_STATUS: - await self._async_teams_status_update(entity) - elif entity.entity_type == SENSOR_TEAMS_CHAT: - await self._async_teams_chat_update(entity) - elif entity.entity_type == SENSOR_TODO: - await self._async_todos_update(entity) - elif entity.entity_type == SENSOR_AUTO_REPLY: - await self._async_auto_reply_update(entity) - - return self._data - - async def _async_email_update(self, entity): - """Update code.""" - data = await self.hass.async_add_executor_job( # pylint: disable=no-member - ft.partial( - entity.mail_folder.get_messages, - limit=entity.max_items, - query=entity.query, - download_attachments=entity.download_attachments, - ) - ) - attrs = await self.hass.async_add_executor_job( # pylint: disable=no-member - self._get_attributes, data, entity - ) - attrs.sort(key=itemgetter("received"), reverse=True) - self._data[entity.entity_key] = { - ATTR_STATE: len(attrs), - ATTR_ATTRIBUTES: {ATTR_DATA: attrs}, - } - - def _get_attributes(self, data, entity): - return [ - get_email_attributes(x, entity.download_attachments, entity.html_body) - for x in data + entities = conf[CONF_ENTITIES] + sensorentities = [ + entity + for entity in entities + if entity.entity_type + in [ + SENSOR_MAIL, + SENSOR_TEAMS_STATUS, + SENSOR_TEAMS_CHAT, + # SENSOR_TODO, + SENSOR_AUTO_REPLY, ] - - async def _async_teams_status_update(self, entity): - """Update state.""" - if data := await self.hass.async_add_executor_job(entity.teams.get_my_presence): - self._data[entity.entity_key] = {ATTR_STATE: data.activity} - - async def _async_teams_chat_update(self, entity): - """Update state.""" - state = None - data = [] - self._data[entity.entity_key] = {} - extra_attributes = {} - chats = await self.hass.async_add_executor_job( - ft.partial(entity.teams.get_my_chats, limit=20) - ) - for chat in chats: - if chat.chat_type == "unknownFutureValue": - continue - if not state: - messages = await self.hass.async_add_executor_job( - ft.partial(chat.get_messages, limit=10) - ) - state, extra_attributes = self._process_chat_messages(messages) - - if not entity.enable_update: - if state: - break - continue - - memberlist = await self._async_get_memberlist(chat) - chatitems = { - ATTR_CHAT_ID: chat.object_id, - ATTR_CHAT_TYPE: chat.chat_type, - ATTR_MEMBERS: ",".join(memberlist), - } - if chat.chat_type == "group": - chatitems[ATTR_TOPIC] = chat.topic - - data.append(chatitems) - - self._data[entity.entity_key] = ( - {ATTR_STATE: state} | extra_attributes | {ATTR_DATA: data} - ) - - def _process_chat_messages(self, messages): - state = None - extra_attributes = {} - for message in messages: - if not state and message.content != "": - state = message.created_date - extra_attributes = { - ATTR_FROM_DISPLAY_NAME: message.from_display_name, - ATTR_CONTENT: message.content, - ATTR_CHAT_ID: message.chat_id, - ATTR_IMPORTANCE: message.importance, - ATTR_SUBJECT: message.subject, - ATTR_SUMMARY: message.summary, - } - break - return state, extra_attributes - - async def _async_get_memberlist(self, chat): - if chat.object_id in self._chat_members and chat.chat_type != "oneOnOne": - return self._chat_members[chat.object_id] - members = await self.hass.async_add_executor_job(chat.get_members) - memberlist = [member.display_name for member in members] - self._chat_members[chat.object_id] = memberlist - return memberlist - - async def _async_todos_update(self, entity): - """Update state.""" - if entity.entity_key in self._data: - error = self._data[entity.entity_key][ATTR_ERROR] - else: - self._data[entity.entity_key] = {ATTR_TASKS: {}, ATTR_STATE: 0} - error = False - data, error = await self._async_todos_update_query(entity, error) - if not error: - tasks = list(data) - self._data[entity.entity_key][ATTR_TASKS] = tasks - self._data[entity.entity_key][ATTR_STATE] = len(tasks) - task_last_completed = self._zero_date - task_last_created = self._zero_date - for task in tasks: - if task.completed and task.completed > entity.task_last_completed: - self._raise_event( - EVENT_COMPLETED_TASK, - task.task_id, - ATTR_COMPLETED, - task.completed, - ) - if task.completed > task_last_completed: - task_last_completed = task.completed - if task.created and task.created > entity.task_last_created: - self._raise_event( - EVENT_NEW_TASK, task.task_id, ATTR_CREATED, task.created - ) - if task.created > task_last_created: - task_last_created = task.created - - if task_last_completed > self._zero_date: - entity.task_last_completed = task_last_completed - if task_last_created > self._zero_date: - entity.task_last_created = task_last_created - - self._data[entity.entity_key][ATTR_ERROR] = error - - async def _async_todos_update_query(self, entity, error): - data = None - full_query = deepcopy(entity.query) - if entity.start_offset: - start = dt.utcnow() + timedelta(hours=entity.start_offset) - full_query.chain("and").on_attribute("due").greater_equal( - start.strftime("%Y-%m-%dT%H:%M:%S") - ) - if entity.end_offset: - end = dt.utcnow() + timedelta(hours=entity.end_offset) - full_query.chain("and").on_attribute("due").less_equal( - end.strftime("%Y-%m-%dT%H:%M:%S") - ) - - try: - data = await self.hass.async_add_executor_job( # pylint: disable=no-member - ft.partial(entity.todo.get_tasks, batch=100, query=full_query) - ) - if error: - _LOGGER.info("Task list reconnected for: %s", entity.name) - error = False - except HTTPError: - if not error: - _LOGGER.error( - "Task list not found for: %s - Has it been deleted?", - entity.name, - ) - error = True - - return data, error - - async def _async_auto_reply_update(self, entity): - """Update state.""" - if data := await self.hass.async_add_executor_job(entity.mailbox.get_settings): - self._data[entity.entity_key] = { - ATTR_STATE: data.automaticrepliessettings.status.value, - ATTR_AUTOREPLIESSETTINGS: data.automaticrepliessettings, - } - - def _build_entity_id(self, name): - """Build and entity ID.""" - return async_generate_entity_id( - SENSOR_ENTITY_ID_FORMAT, - name, - hass=self.hass, + ] + coordinator = conf[CONF_COORDINATOR] + sensorentities.extend( + O365TasksSensor( + coordinator, + key[CONF_TODO], + key[CONF_NAME], + key[CONF_TASK_LIST], + config, + key[CONF_ENTITY_KEY], + key[CONF_UNIQUE_ID], ) + for key in conf[CONF_KEYS] + ) + async_add_entities(sensorentities, False) + await _async_setup_register_services(hass, conf) - def _raise_event(self, event_type, task_id, time_type, task_datetime): - self.hass.bus.fire( - f"{DOMAIN}_{event_type}", - {ATTR_TASK_ID: task_id, time_type: task_datetime, EVENT_HA_EVENT: False}, - ) - _LOGGER.debug("%s - %s - %s", event_type, task_id, task_datetime) + return True async def _async_setup_register_services(hass, config): @@ -553,7 +105,7 @@ async def _async_setup_task_services(hass, config, perms): ): return - sensor_services = SensorServices(hass) + sensor_services = O365TasksSensorSensorServices(hass) hass.services.async_register( DOMAIN, "scan_for_task_lists", sensor_services.async_scan_for_task_lists ) @@ -618,29 +170,3 @@ async def _async_setup_mailbox_services(config, perms): AUTO_REPLY_SERVICE_DISABLE_SCHEMA, "auto_reply_disable", ) - - -class SensorServices: - """Sensor Services.""" - - def __init__(self, hass): - """Initialise the sensor services.""" - self._hass = hass - - async def async_scan_for_task_lists(self, call): # pylint: disable=unused-argument - """Scan for new task lists.""" - for config in self._hass.data[DOMAIN]: - config = self._hass.data[DOMAIN][config] - todo_sensor = config.get(CONF_TODO_SENSORS) - if todo_sensor and CONF_ACCOUNT in config and todo_sensor.get(CONF_ENABLED): - todos = config[CONF_ACCOUNT].tasks() - - todolists = await self._hass.async_add_executor_job(todos.list_folders) - track = todo_sensor.get(CONF_TRACK_NEW) - for todo in todolists: - update_task_list_file( - config, - todo, - self._hass, - track, - ) diff --git a/custom_components/o365/setup.py b/custom_components/o365/setup.py index 2ed92df..16587ff 100644 --- a/custom_components/o365/setup.py +++ b/custom_components/o365/setup.py @@ -9,8 +9,11 @@ CONF_AUTO_REPLY_SENSORS, CONF_CHAT_SENSORS, CONF_CONFIG_TYPE, + CONF_COORDINATOR, CONF_EMAIL_SENSORS, CONF_ENABLE_UPDATE, + CONF_ENTITIES, + CONF_KEYS, CONF_PERMISSIONS, CONF_QUERY_SENSORS, CONF_STATUS_SENSORS, @@ -18,9 +21,10 @@ CONF_TRACK_NEW_CALENDAR, DOMAIN, ) +from .coordinator import O365SensorCordinator -def do_setup(hass, config, account, account_name, conf_type, perms): +async def do_setup(hass, config, account, account_name, conf_type, perms): """Run the setup after we have everything configured.""" email_sensors = config.get(CONF_EMAIL_SENSORS, []) query_sensors = config.get(CONF_QUERY_SENSORS, []) @@ -48,6 +52,13 @@ def do_setup(hass, config, account, account_name, conf_type, perms): hass.data[DOMAIN] = {} hass.data[DOMAIN][account_name] = account_config + coordinator = O365SensorCordinator(hass, account_config) + entities, keys = await coordinator.async_setup_entries() + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][account_name][CONF_ENTITIES] = entities + hass.data[DOMAIN][account_name][CONF_KEYS] = keys + hass.data[DOMAIN][account_name][CONF_COORDINATOR] = coordinator + _load_platforms(hass, account_name, config, account_config)