diff --git a/custom_components/o365/classes/sensorentity.py b/custom_components/o365/classes/entity.py similarity index 83% rename from custom_components/o365/classes/sensorentity.py rename to custom_components/o365/classes/entity.py index eaa8f7c..0accffd 100644 --- a/custom_components/o365/classes/sensorentity.py +++ b/custom_components/o365/classes/entity.py @@ -2,10 +2,10 @@ import voluptuous as vol from homeassistant.helpers.update_coordinator import CoordinatorEntity -from ..const import ATTR_DATA, ATTR_STATE, CONF_PERMISSIONS +from ..const import ATTR_DATA, CONF_PERMISSIONS -class O365Sensor(CoordinatorEntity): +class O365Entity(CoordinatorEntity): """O365 generic Sensor class.""" _attr_should_poll = False @@ -30,11 +30,6 @@ def entity_key(self): """Entity Key property.""" return self._entity_id - @property - def native_value(self): - """Sensor state.""" - return self.coordinator.data[self.entity_key][ATTR_STATE] - @property def unique_id(self): """Entity unique id.""" diff --git a/custom_components/o365/classes/mailsensor.py b/custom_components/o365/classes/mailsensor.py index 074475e..4f3e551 100644 --- a/custom_components/o365/classes/mailsensor.py +++ b/custom_components/o365/classes/mailsensor.py @@ -30,10 +30,10 @@ SENSOR_EMAIL, ) from ..utils.utils import clean_html, get_email_attributes -from .sensorentity import O365Sensor +from .entity import O365Entity -class O365MailSensor(O365Sensor, SensorEntity): +class O365MailSensor(O365Entity, SensorEntity): """O365 generic Mail Sensor class.""" def __init__(self, coordinator, config, sensor_conf, name, entity_id, unique_id): @@ -74,7 +74,7 @@ def _get_attributes(self, data): ] -class O365AutoReplySensor(O365Sensor, SensorEntity): +class O365AutoReplySensor(O365Entity, SensorEntity): """O365 Auto Reply sensor processing.""" def __init__(self, coordinator, name, entity_id, config, unique_id): diff --git a/custom_components/o365/classes/taskssensor.py b/custom_components/o365/classes/taskssensor.py index c4e84f8..92843dc 100644 --- a/custom_components/o365/classes/taskssensor.py +++ b/custom_components/o365/classes/taskssensor.py @@ -4,7 +4,6 @@ import voluptuous as vol from homeassistant.components.sensor import SensorEntity -from homeassistant.const import CONF_ENABLED from homeassistant.util import dt from ..const import ( @@ -18,13 +17,10 @@ ATTR_REMINDER, ATTR_SUBJECT, ATTR_TASK_ID, - CONF_ACCOUNT, CONF_DUE_HOURS_BACKWARD_TO_GET, CONF_DUE_HOURS_FORWARD_TO_GET, CONF_SHOW_COMPLETED, CONF_TASK_LIST, - CONF_TODO_SENSORS, - CONF_TRACK_NEW, DATETIME_FORMAT, DOMAIN, EVENT_COMPLETED_TASK, @@ -35,20 +31,19 @@ EVENT_UPDATE_TASK, PERM_MINIMUM_TASKS_WRITE, PERM_TASKS_READWRITE, - SENSOR_TODO, + TODO_TODO, ) -from ..utils.filemgmt import update_task_list_file -from .sensorentity import O365Sensor +from .entity import O365Entity _LOGGER = logging.getLogger(__name__) -class O365TasksSensor(O365Sensor, SensorEntity): +class O365TasksSensor(O365Entity, SensorEntity): """O365 Tasks sensor processing.""" def __init__(self, coordinator, todo, name, task, config, entity_id, unique_id): """Initialise the Tasks Sensor.""" - super().__init__(coordinator, config, name, entity_id, SENSOR_TODO, unique_id) + super().__init__(coordinator, config, name, entity_id, TODO_TODO, unique_id) self.todo = todo self._show_completed = task.get(CONF_SHOW_COMPLETED) @@ -74,8 +69,8 @@ def extra_state_attributes(self): return self._extra_attributes def _handle_coordinator_update(self) -> None: - tasks = list(self.coordinator.data[self.entity_key][ATTR_DATA]) - self._state = len(tasks) + tasks = self.coordinator.data[self.entity_key][ATTR_DATA] + self._state = sum(not task.completed for task in tasks) self._extra_attributes = self._update_extra_state_attributes(tasks) task_last_completed = self._zero_date @@ -265,29 +260,3 @@ def build_todo_query(key, todo): end.strftime("%Y-%m-%dT%H:%M:%S") ) return query - - -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/classes/teamssensor.py b/custom_components/o365/classes/teamssensor.py index 8caace5..d30266f 100644 --- a/custom_components/o365/classes/teamssensor.py +++ b/custom_components/o365/classes/teamssensor.py @@ -10,6 +10,7 @@ ATTR_DATA, ATTR_FROM_DISPLAY_NAME, ATTR_IMPORTANCE, + ATTR_STATE, ATTR_SUBJECT, ATTR_SUMMARY, CONF_ACCOUNT, @@ -21,12 +22,12 @@ SENSOR_TEAMS_CHAT, SENSOR_TEAMS_STATUS, ) -from .sensorentity import O365Sensor +from .entity import O365Entity _LOGGER = logging.getLogger(__name__) -class O365TeamsSensor(O365Sensor): +class O365TeamsSensor(O365Entity): """O365 Teams sensor processing.""" def __init__(self, cordinator, name, entity_id, config, entity_type, unique_id): @@ -39,6 +40,11 @@ def icon(self): """Entity icon.""" return "mdi:microsoft-teams" + @property + def native_value(self): + """Sensor state.""" + return self.coordinator.data[self.entity_key][ATTR_STATE] + class O365TeamsStatusSensor(O365TeamsSensor, SensorEntity): """O365 Teams sensor processing.""" diff --git a/custom_components/o365/const.py b/custom_components/o365/const.py index ae88de3..2c424b4 100644 --- a/custom_components/o365/const.py +++ b/custom_components/o365/const.py @@ -133,6 +133,8 @@ class EventResponse(Enum): DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z" DEFAULT_OFFSET = "!!" DOMAIN = "o365" +ENTITY_ID_FORMAT_SENSOR = "sensor.{}" +ENTITY_ID_FORMAT_TODO = "todo.{}" EVENT_HA_EVENT = "ha_event" EVENT_COMPLETED_TASK = "completed_task" @@ -206,11 +208,10 @@ class EventResponse(Enum): PERM_SHARED = ".Shared" SENSOR_AUTO_REPLY = "auto_reply" -SENSOR_ENTITY_ID_FORMAT = "sensor.{}" SENSOR_EMAIL = "inbox" SENSOR_TEAMS_STATUS = "teams_status" SENSOR_TEAMS_CHAT = "teams_chat" -SENSOR_TODO = "todo" +TODO_TODO = "todo" TOKEN_FILENAME = "o365{0}.token" # nosec TOKEN_FILE_MISSING = "missing" YAML_CALENDARS = "{0}_calendars{1}.yaml" diff --git a/custom_components/o365/coordinator.py b/custom_components/o365/coordinator.py index 7e22d13..86ee3ca 100644 --- a/custom_components/o365/coordinator.py +++ b/custom_components/o365/coordinator.py @@ -4,13 +4,13 @@ from datetime import datetime, timedelta from homeassistant.const import CONF_ENABLED, CONF_NAME, CONF_UNIQUE_ID +from homeassistant.helpers import entity_registry 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 build_inbox_query, build_query_query -from .classes.taskssensor import O365TasksSensorSensorServices, build_todo_query from .const import ( ATTR_AUTOREPLIESSETTINGS, ATTR_CHAT_ID, @@ -49,17 +49,19 @@ CONF_TODO_SENSORS, CONF_TRACK, DOMAIN, + ENTITY_ID_FORMAT_SENSOR, + ENTITY_ID_FORMAT_TODO, EVENT_HA_EVENT, LEGACY_ACCOUNT_NAME, SENSOR_AUTO_REPLY, SENSOR_EMAIL, - SENSOR_ENTITY_ID_FORMAT, SENSOR_TEAMS_CHAT, SENSOR_TEAMS_STATUS, - SENSOR_TODO, + TODO_TODO, YAML_TASK_LISTS, ) from .schema import TASK_LIST_SCHEMA +from .todo import O365TodoEntityServices, build_todo_query from .utils.filemgmt import build_config_file_path, build_yaml_filename, load_yaml_file _LOGGER = logging.getLogger(__name__) @@ -85,6 +87,7 @@ def __init__(self, hass, config): self._data = {} self._zero_date = datetime(1, 1, 1, 0, 0, 0, tzinfo=dt.DEFAULT_TIME_ZONE) self._chat_members = {} + self._ent_reg = entity_registry.async_get(hass) async def async_setup_entries(self): """Do the initial setup of the entities.""" @@ -119,7 +122,9 @@ async def _async_email_sensors(self): sensor_conf, CONF_EMAIL_SENSORS ): new_key = { - CONF_ENTITY_KEY: self._build_entity_id(name), + CONF_ENTITY_KEY: self._build_entity_id( + ENTITY_ID_FORMAT_SENSOR, name + ), CONF_UNIQUE_ID: f"{mail_folder.folder_id}_{self._account_name}", CONF_SENSOR_CONF: sensor_conf, CONF_O365_MAIL_FOLDER: mail_folder, @@ -140,7 +145,9 @@ async def _async_query_sensors(self): ): name = sensor_conf.get(CONF_NAME) new_key = { - CONF_ENTITY_KEY: self._build_entity_id(name), + CONF_ENTITY_KEY: self._build_entity_id( + ENTITY_ID_FORMAT_SENSOR, name + ), CONF_UNIQUE_ID: f"{mail_folder.folder_id}_{self._account_name}", CONF_SENSOR_CONF: sensor_conf, CONF_O365_MAIL_FOLDER: mail_folder, @@ -157,7 +164,7 @@ def _status_sensors(self): for sensor_conf in status_sensors: name = sensor_conf.get(CONF_NAME) new_key = { - CONF_ENTITY_KEY: self._build_entity_id(name), + CONF_ENTITY_KEY: self._build_entity_id(ENTITY_ID_FORMAT_SENSOR, name), CONF_UNIQUE_ID: f"{name}_{self._account_name}", CONF_NAME: name, CONF_ENTITY_TYPE: SENSOR_TEAMS_STATUS, @@ -172,7 +179,7 @@ def _chat_sensors(self): for sensor_conf in chat_sensors: name = sensor_conf.get(CONF_NAME) new_key = { - CONF_ENTITY_KEY: self._build_entity_id(name), + CONF_ENTITY_KEY: self._build_entity_id(ENTITY_ID_FORMAT_SENSOR, name), CONF_UNIQUE_ID: f"{name}_{self._account_name}", CONF_NAME: name, CONF_ENTITY_TYPE: SENSOR_TEAMS_CHAT, @@ -186,7 +193,7 @@ async def _async_todo_sensors(self): todo_sensors = self._config.get(CONF_TODO_SENSORS) keys = [] if todo_sensors and todo_sensors.get(CONF_ENABLED): - sensor_services = O365TasksSensorSensorServices(self.hass) + sensor_services = O365TodoEntityServices(self.hass) await sensor_services.async_scan_for_task_lists(None) yaml_filename = build_yaml_filename(self._config, YAML_TASK_LISTS) @@ -221,17 +228,21 @@ async def _async_todo_entities(self, task_lists): ) ) ) - + unique_id = f"{task_list_id}_{self._account_name}" new_key = { - CONF_ENTITY_KEY: self._build_entity_id(name), - CONF_UNIQUE_ID: f"{task_list_id}_{self._account_name}", + CONF_ENTITY_KEY: self._build_entity_id(ENTITY_ID_FORMAT_TODO, name), + CONF_UNIQUE_ID: unique_id, CONF_TODO: todo, CONF_NAME: name, CONF_TASK_LIST: tasklist, - CONF_ENTITY_TYPE: SENSOR_TODO, + CONF_ENTITY_TYPE: TODO_TODO, } keys.append(new_key) + # To be deleted in mid 2024 after majority have migtated + # to HA 2023.11 and O365 version 4.5 + await self._async_delete_redundant_sensors(unique_id) + except HTTPError: _LOGGER.warning( "Task list not found for: %s - Please remove from O365_tasks_%s.yaml", @@ -246,7 +257,7 @@ async def _async_auto_reply_sensors(self): for sensor_conf in auto_reply_sensors: name = sensor_conf.get(CONF_NAME) new_key = { - CONF_ENTITY_KEY: self._build_entity_id(name), + CONF_ENTITY_KEY: self._build_entity_id(ENTITY_ID_FORMAT_SENSOR, name), CONF_UNIQUE_ID: f"{name}_{self._account_name}", CONF_NAME: name, CONF_ENTITY_TYPE: SENSOR_AUTO_REPLY, @@ -295,7 +306,7 @@ async def _async_update_data(self): entity_type = key[CONF_ENTITY_TYPE] if entity_type == SENSOR_EMAIL: await self._async_email_update(key) - elif entity_type == SENSOR_TODO: + elif entity_type == TODO_TODO: await self._async_todos_update(key) elif entity_type == SENSOR_TEAMS_CHAT: await self._async_teams_chat_update(key) @@ -326,7 +337,7 @@ async def _async_email_update(self, key): self._data[entity_key] = { ATTR_DATA: await self.hass.async_add_executor_job(list, data) } - + async def _async_teams_status_update(self, key): """Update state.""" entity_key = key[CONF_ENTITY_KEY] @@ -408,7 +419,9 @@ async def _async_todos_update(self, key): error = False data, error = await self._async_todos_update_query(key, error) if not error: - self._data[entity_key][ATTR_DATA] = data + self._data[entity_key][ATTR_DATA] = await self.hass.async_add_executor_job( + list, data + ) self._data[entity_key][ATTR_ERROR] = error @@ -446,10 +459,10 @@ async def _async_auto_reply_update(self, key): ATTR_AUTOREPLIESSETTINGS: data.automaticrepliessettings, } - def _build_entity_id(self, name): + def _build_entity_id(self, entity_id_format, name): """Build and entity ID.""" return async_generate_entity_id( - SENSOR_ENTITY_ID_FORMAT, + entity_id_format, name, hass=self.hass, ) @@ -460,3 +473,7 @@ def _raise_event(self, event_type, task_id, time_type, task_datetime): {ATTR_TASK_ID: task_id, time_type: task_datetime, EVENT_HA_EVENT: False}, ) _LOGGER.debug("%s - %s - %s", event_type, task_id, task_datetime) + + async def _async_delete_redundant_sensors(self, unique_id): + if entity_id := self._ent_reg.async_get_entity_id("sensor", DOMAIN, unique_id): + self._ent_reg.async_remove(entity_id) diff --git a/custom_components/o365/manifest.json b/custom_components/o365/manifest.json index 5e545e6..83a3b1a 100644 --- a/custom_components/o365/manifest.json +++ b/custom_components/o365/manifest.json @@ -15,5 +15,5 @@ "O365==2.0.28", "BeautifulSoup4>=4.10.0" ], - "version": "v4.4.3" + "version": "v4.5.0b1" } \ No newline at end of file diff --git a/custom_components/o365/sensor.py b/custom_components/o365/sensor.py index 4a04af0..122eb14 100644 --- a/custom_components/o365/sensor.py +++ b/custom_components/o365/sensor.py @@ -2,16 +2,17 @@ import logging -from homeassistant.const import CONF_ENABLED, CONF_NAME, CONF_UNIQUE_ID +from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID from homeassistant.helpers import entity_platform from .classes.mailsensor import O365AutoReplySensor, O365MailSensor -from .classes.taskssensor import O365TasksSensor, O365TasksSensorSensorServices + +# from .classes.taskssensor import O365TasksSensor from .classes.teamssensor import O365TeamsChatSensor, O365TeamsStatusSensor +from .const import CONF_ACCOUNT # CONF_TASK_LIST,; CONF_TODO, from .const import ( - CONF_ACCOUNT, CONF_ACCOUNT_NAME, - CONF_AUTO_REPLY_SENSORS, + CONF_AUTO_REPLY_SENSORS, # TODO_TODO, CONF_CHAT_SENSORS, CONF_COORDINATOR, CONF_ENABLE_UPDATE, @@ -20,27 +21,18 @@ CONF_KEYS, CONF_PERMISSIONS, CONF_SENSOR_CONF, - CONF_TASK_LIST, - CONF_TODO, - CONF_TODO_SENSORS, DOMAIN, PERM_MINIMUM_CHAT_WRITE, PERM_MINIMUM_MAILBOX_SETTINGS, - PERM_MINIMUM_TASKS_WRITE, SENSOR_AUTO_REPLY, SENSOR_EMAIL, SENSOR_TEAMS_CHAT, SENSOR_TEAMS_STATUS, - SENSOR_TODO, ) from .schema import ( AUTO_REPLY_SERVICE_DISABLE_SCHEMA, AUTO_REPLY_SERVICE_ENABLE_SCHEMA, CHAT_SERVICE_SEND_MESSAGE_SCHEMA, - TASK_SERVICE_COMPLETE_SCHEMA, - TASK_SERVICE_DELETE_SCHEMA, - TASK_SERVICE_NEW_SCHEMA, - TASK_SERVICE_UPDATE_SCHEMA, ) _LOGGER = logging.getLogger(__name__) @@ -75,18 +67,18 @@ async def async_setup_platform( key[CONF_UNIQUE_ID], ) ) - elif key[CONF_ENTITY_TYPE] == SENSOR_TODO: - sensorentities.append( - O365TasksSensor( - coordinator, - key[CONF_TODO], - key[CONF_NAME], - key[CONF_TASK_LIST], - conf, - key[CONF_ENTITY_KEY], - key[CONF_UNIQUE_ID], - ) - ) + # elif key[CONF_ENTITY_TYPE] == TODO_TODO: + # sensorentities.append( + # O365TasksSensor( + # coordinator, + # key[CONF_TODO], + # key[CONF_NAME], + # key[CONF_TASK_LIST], + # conf, + # key[CONF_ENTITY_KEY], + # key[CONF_UNIQUE_ID], + # ) + # ) elif key[CONF_ENTITY_TYPE] == SENSOR_TEAMS_CHAT: sensorentities.append( O365TeamsChatSensor( @@ -119,56 +111,17 @@ async def async_setup_platform( ) async_add_entities(sensorentities, False) - await _async_setup_register_services(hass, conf) + await _async_setup_register_services(conf) return True -async def _async_setup_register_services(hass, config): +async def _async_setup_register_services(config): perms = config[CONF_PERMISSIONS] - await _async_setup_task_services(hass, config, perms) await _async_setup_chat_services(config, perms) await _async_setup_mailbox_services(config, perms) -async def _async_setup_task_services(hass, config, perms): - todo_sensors = config.get(CONF_TODO_SENSORS) - if ( - not todo_sensors - or not todo_sensors.get(CONF_ENABLED) - or not todo_sensors.get(CONF_ENABLE_UPDATE) - ): - return - - sensor_services = O365TasksSensorSensorServices(hass) - hass.services.async_register( - DOMAIN, "scan_for_task_lists", sensor_services.async_scan_for_task_lists - ) - - platform = entity_platform.async_get_current_platform() - if perms.validate_minimum_permission(PERM_MINIMUM_TASKS_WRITE): - platform.async_register_entity_service( - "new_task", - TASK_SERVICE_NEW_SCHEMA, - "new_task", - ) - platform.async_register_entity_service( - "update_task", - TASK_SERVICE_UPDATE_SCHEMA, - "update_task", - ) - platform.async_register_entity_service( - "delete_task", - TASK_SERVICE_DELETE_SCHEMA, - "delete_task", - ) - platform.async_register_entity_service( - "complete_task", - TASK_SERVICE_COMPLETE_SCHEMA, - "complete_task", - ) - - async def _async_setup_chat_services(config, perms): chat_sensors = config.get(CONF_CHAT_SENSORS) if not chat_sensors: diff --git a/custom_components/o365/services.yaml b/custom_components/o365/services.yaml index c62c08f..86e3643 100644 --- a/custom_components/o365/services.yaml +++ b/custom_components/o365/services.yaml @@ -2,9 +2,9 @@ scan_for_calendars: name: Scan for new calendars description: "Scan for newly available calendars" -scan_for_task_lists: - name: Scan for new task lists - description: "Scan for newly available task lists" +scan_for_todo_lists: + name: Scan for new todo lists + description: "Scan for newly available todo lists" respond_calendar_event: name: Respond to an event @@ -258,31 +258,31 @@ remove_calendar_event: text: new_task: - name: Create a task/ToDo - description: Create a new task/ToDo + name: Create a ToDo + description: Create a new ToDo target: device: integration: o365 entity: integration: o365 - domain: sensor + domain: todo fields: subject: name: Subject - description: The subject of the task + description: The subject of the todo example: Pick up the mail required: true selector: text: description: name: Description - description: Description of the task + description: Description of the todo example: Walk to the post box and collect the mail selector: text: due: name: Due date - description: When the task is due by + description: When the todo is due by example: "YYYY-MM-DD" selector: date: @@ -294,37 +294,37 @@ new_task: text: update_task: - name: Update a task/ToDo - description: Update a new task/ToDo + name: Update a ToDo + description: Update a ToDo target: device: integration: o365 entity: integration: o365 - domain: sensor + domain: todo fields: task_id: - name: Task ID - description: ID for the task, can be found as an attribute on your task + name: ToDo ID + description: ID for the todo, can be found as an attribute on your todo example: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx required: true selector: text: subject: name: Subject - description: The subject of the task + description: The subject of the todo example: Pick up the mail selector: text: description: name: Description - description: Description of the task + description: Description of the todo example: Walk to the post box and collect the mail selector: text: due: name: Due date - description: When the task is due by + description: When the todo is due by example: "2023-01-01" selector: date: @@ -336,48 +336,50 @@ update_task: text: delete_task: - name: Delete a task/ToDo - description: Delete a new task/ToDo + name: Delete a ToDo + description: Delete a ToDo target: device: integration: o365 entity: integration: o365 - domain: sensor + domain: todo fields: task_id: - name: Task ID - description: ID for the task, can be found as an attribute on your task + name: ToDo ID + description: ID for the todo, can be found as an attribute on your todo example: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx required: true selector: text: complete_task: - name: Complete a task/ToDo - description: Complete a new task/ToDo + name: Complete a ToDo + description: Complete a ToDo target: device: integration: o365 entity: integration: o365 - domain: sensor + domain: todo fields: task_id: - name: Task ID - description: ID for the task, can be found as an attribute on your task + name: Todo ID + description: ID for the todo, can be found as an attribute on your todo example: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx required: true selector: text: completed: name: Completed - description: Set whether task is completed or not + description: Set whether todo is completed or not example: True required: true selector: boolean: + + auto_reply_enable: name: Auto reply enable description: Schedules auto reply diff --git a/custom_components/o365/setup.py b/custom_components/o365/setup.py index bccda6c..be4ff86 100644 --- a/custom_components/o365/setup.py +++ b/custom_components/o365/setup.py @@ -77,13 +77,18 @@ def _load_platforms(hass, account_name, config, account_config): or len(account_config[CONF_QUERY_SENSORS]) > 0 or len(account_config[CONF_STATUS_SENSORS]) > 0 or len(account_config[CONF_CHAT_SENSORS]) > 0 - or ( - len(account_config[CONF_TODO_SENSORS]) > 0 - and account_config[CONF_TODO_SENSORS].get(CONF_ENABLED, False) - ) ): hass.async_create_task( discovery.async_load_platform( hass, "sensor", DOMAIN, {CONF_ACCOUNT_NAME: account_name}, config ) ) + + if len(account_config[CONF_TODO_SENSORS]) > 0 and account_config[ + CONF_TODO_SENSORS + ].get(CONF_ENABLED, False): + hass.async_create_task( + discovery.async_load_platform( + hass, "todo", DOMAIN, {CONF_ACCOUNT_NAME: account_name}, config + ) + ) diff --git a/custom_components/o365/todo.py b/custom_components/o365/todo.py new file mode 100644 index 0000000..0d068dd --- /dev/null +++ b/custom_components/o365/todo.py @@ -0,0 +1,460 @@ +"""Todo processing.""" + +import logging +from datetime import datetime, timedelta + +import voluptuous as vol +from homeassistant.components.todo import TodoItem, TodoListEntity +from homeassistant.components.todo.const import TodoItemStatus, TodoListEntityFeature +from homeassistant.const import CONF_ENABLED, CONF_NAME, CONF_UNIQUE_ID +from homeassistant.helpers import entity_platform +from homeassistant.util import dt + +from .classes.entity import O365Entity +from .const import ( + ATTR_ALL_TASKS, + ATTR_COMPLETED, + ATTR_CREATED, + ATTR_DATA, + ATTR_DESCRIPTION, + ATTR_DUE, + ATTR_OVERDUE_TASKS, + ATTR_REMINDER, + ATTR_SUBJECT, + ATTR_TASK_ID, + CONF_ACCOUNT, + CONF_ACCOUNT_NAME, + CONF_COORDINATOR, + CONF_DUE_HOURS_BACKWARD_TO_GET, + CONF_DUE_HOURS_FORWARD_TO_GET, + CONF_ENABLE_UPDATE, + CONF_ENTITY_KEY, + CONF_ENTITY_TYPE, + CONF_KEYS, + CONF_PERMISSIONS, + CONF_SHOW_COMPLETED, + CONF_TASK_LIST, + CONF_TODO, + CONF_TODO_SENSORS, + CONF_TRACK_NEW, + DATETIME_FORMAT, + DOMAIN, + EVENT_COMPLETED_TASK, + EVENT_DELETE_TASK, + EVENT_HA_EVENT, + EVENT_NEW_TASK, + EVENT_UNCOMPLETED_TASK, + EVENT_UPDATE_TASK, + PERM_MINIMUM_TASKS_WRITE, + PERM_TASKS_READWRITE, + TODO_TODO, +) +from .schema import ( + TASK_SERVICE_COMPLETE_SCHEMA, + TASK_SERVICE_DELETE_SCHEMA, + TASK_SERVICE_NEW_SCHEMA, + TASK_SERVICE_UPDATE_SCHEMA, +) +from .utils.filemgmt import update_task_list_file + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None +): # pylint: disable=unused-argument + """O365 platform definition.""" + if discovery_info is None: + return None + + account_name = discovery_info[CONF_ACCOUNT_NAME] + conf = hass.data[DOMAIN][account_name] + account = conf[CONF_ACCOUNT] + + is_authenticated = account.is_authenticated + if not is_authenticated: + return False + + coordinator = conf[CONF_COORDINATOR] + todoentities = [ + O365TodoList( + hass, + coordinator, + key[CONF_TODO], + key[CONF_NAME], + key[CONF_TASK_LIST], + conf, + key[CONF_ENTITY_KEY], + key[CONF_UNIQUE_ID], + ) + for key in conf[CONF_KEYS] + if key[CONF_ENTITY_TYPE] == TODO_TODO + ] + async_add_entities(todoentities, False) + await _async_setup_register_services(hass, conf) + + return True + + +async def _async_setup_register_services(hass, config): + perms = config[CONF_PERMISSIONS] + await _async_setup_task_services(hass, config, perms) + + +async def _async_setup_task_services(hass, config, perms): + todo_sensors = config.get(CONF_TODO_SENSORS) + if ( + not todo_sensors + or not todo_sensors.get(CONF_ENABLED) + or not todo_sensors.get(CONF_ENABLE_UPDATE) + ): + return + + sensor_services = O365TodoEntityServices(hass) + hass.services.async_register( + DOMAIN, "scan_for_task_lists", sensor_services.async_scan_for_task_lists + ) + + platform = entity_platform.async_get_current_platform() + if perms.validate_minimum_permission(PERM_MINIMUM_TASKS_WRITE): + platform.async_register_entity_service( + "new_todo", + TASK_SERVICE_NEW_SCHEMA, + "async_new_todo", + ) + platform.async_register_entity_service( + "update_todo", + TASK_SERVICE_UPDATE_SCHEMA, + "async_update_todo", + ) + platform.async_register_entity_service( + "delete_todo", + TASK_SERVICE_DELETE_SCHEMA, + "async_delete_todo", + ) + platform.async_register_entity_service( + "complete_todo", + TASK_SERVICE_COMPLETE_SCHEMA, + "async_complete_todo", + ) + + +class O365TodoList(O365Entity, TodoListEntity): + """O365 ToDo processing.""" + + def __init__( + self, hass, coordinator, todolist, name, task, config, entity_id, unique_id + ): + """Initialise the ToDo List.""" + super().__init__(coordinator, config, name, entity_id, TODO_TODO, unique_id) + self.todolist = todolist + self._show_completed = task.get(CONF_SHOW_COMPLETED) + + 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) + self._state = None + self._todo_items = None + self._extra_attributes = None + self._update_status(hass) + if config.get(CONF_TODO_SENSORS).get(CONF_ENABLE_UPDATE): + self._attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + ) + + @property + def icon(self): + """Entity icon.""" + return "mdi:clipboard-check-outline" + + @property + def state(self): + """Todo state.""" + return self._state + + @property + def todo_items(self): + """List of Todos.""" + return self._todo_items + + @property + def extra_state_attributes(self): + """Device state attributes.""" + return self._extra_attributes + + def _handle_coordinator_update(self) -> None: + self._update_status(self.hass) + self.async_write_ha_state() + + def _update_status(self, hass): + tasks = self.coordinator.data[self.entity_key][ATTR_DATA] + self._state = sum(not task.completed for task in tasks) + self._todo_items = [] + for task in tasks: + completed = ( + TodoItemStatus.COMPLETED + if task.completed + else TodoItemStatus.NEEDS_ACTION + ) + self._todo_items.append( + TodoItem(uid=task.task_id, summary=task.subject, status=completed) + ) + + self._extra_attributes = self._update_extra_state_attributes(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: + _raise_event_external( + hass, + 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: + _raise_event_external( + hass, 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 + + def _update_extra_state_attributes(self, tasks): + """Extra state attributes.""" + all_tasks = [] + overdue_tasks = [] + for item in tasks: + task = {ATTR_SUBJECT: item.subject, ATTR_TASK_ID: item.task_id} + if item.body: + task[ATTR_DESCRIPTION] = item.body + if self._show_completed: + task[ATTR_COMPLETED] = ( + item.completed.strftime(DATETIME_FORMAT) + if item.completed + else False + ) + if item.due: + due = item.due.date() + task[ATTR_DUE] = due + if due < dt.utcnow().date(): + overdue_task = { + ATTR_SUBJECT: item.subject, + ATTR_TASK_ID: item.task_id, + ATTR_DUE: due, + } + if item.is_reminder_on: + overdue_task[ATTR_REMINDER] = item.reminder + overdue_tasks.append(overdue_task) + + if item.is_reminder_on: + task[ATTR_REMINDER] = item.reminder + + all_tasks.append(task) + + extra_attributes = {ATTR_ALL_TASKS: all_tasks} + if overdue_tasks: + extra_attributes[ATTR_OVERDUE_TASKS] = overdue_tasks + return extra_attributes + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + await self.async_new_todo(subject=item.summary) + + async def async_new_todo(self, subject, description=None, due=None, reminder=None): + """Create a new task for this task list.""" + if not self._validate_task_permissions(): + return False + + new_task = self.todolist.new_task() + await self._async_save_task(new_task, subject, description, due, reminder) + self._raise_event(EVENT_NEW_TASK, new_task.task_id) + self.task_last_created = new_task.created + await self.coordinator.async_refresh() + return True + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + task = await self.hass.async_add_executor_job(self.todolist.get_task, item.uid) + if item.summary and item.summary != task.subject: + await self.async_update_todo( + task_id=item.uid, subject=item.summary, task=task + ) + if item.status: + completed = None + if item.status == TodoItemStatus.COMPLETED and not task.completed: + completed = True + elif item.status == TodoItemStatus.NEEDS_ACTION and task.completed: + completed = False + if completed is not None: + await self.async_complete_todo(item.uid, completed, task=task) + + async def async_update_todo( + self, + task_id, + subject=None, + description=None, + due=None, + reminder=None, + task=None, + ): + """Update a task for this task list.""" + if not self._validate_task_permissions(): + return False + + if not task: + task = await self.hass.async_add_executor_job( + self.todolist.get_task, task_id + ) + await self._async_save_task(task, subject, description, due, reminder) + self._raise_event(EVENT_UPDATE_TASK, task_id) + await self.coordinator.async_refresh() + return True + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete items from the To-do list.""" + for task_id in uids: + await self.async_delete_todo(task_id) + + async def async_delete_todo(self, task_id): + """Delete task for this task list.""" + if not self._validate_task_permissions(): + return False + + task = await self.hass.async_add_executor_job(self.todolist.get_task, task_id) + await self.hass.async_add_executor_job(task.delete) + self._raise_event(EVENT_DELETE_TASK, task_id) + await self.coordinator.async_refresh() + return True + + async def async_complete_todo(self, task_id, completed, task=None): + """Complete task for this task list.""" + if not self._validate_task_permissions(): + return False + + if not task: + task = await self.hass.async_add_executor_job( + self.todolist.get_task, task_id + ) + if completed: + await self._async_complete_task(task, task_id) + else: + await self._async_uncomplete_task(task, task_id) + + await self.coordinator.async_refresh() + return True + + async def _async_complete_task(self, task, task_id): + if task.completed: + raise vol.Invalid("ToDo is already completed") + task.mark_completed() + self.hass.async_add_executor_job(task.save) + self._raise_event(EVENT_COMPLETED_TASK, task_id) + self.task_last_completed = dt.utcnow() + + async def _async_uncomplete_task(self, task, task_id): + if not task.completed: + raise vol.Invalid("ToDo has not been completed previously") + task.mark_uncompleted() + self.hass.async_add_executor_job(task.save) + self._raise_event(EVENT_UNCOMPLETED_TASK, task_id) + + async def _async_save_task(self, task, subject, description, due, reminder): + # sourcery skip: raise-from-previous-error + if subject: + task.subject = subject + if description: + task.body = description + + if due: + try: + if len(due) > 10: + task.due = dt.parse_datetime(due).date() + else: + task.due = dt.parse_date(due) + except ValueError: + error = f"Due date {due} is not in valid format YYYY-MM-DD" + raise vol.Invalid(error) # pylint: disable=raise-missing-from + + if reminder: + task.reminder = reminder + + await self.hass.async_add_executor_job(task.save) + + def _raise_event(self, event_type, task_id): + self.hass.bus.fire( + f"{DOMAIN}_{event_type}", + {ATTR_TASK_ID: task_id, EVENT_HA_EVENT: True}, + ) + _LOGGER.debug("%s - %s", event_type, task_id) + + def _validate_task_permissions(self): + return self._validate_permissions( + PERM_MINIMUM_TASKS_WRITE, + f"Not authorised to create new ToDo - requires permission: {PERM_TASKS_READWRITE}", + ) + + +def _raise_event_external(hass, event_type, task_id, time_type, task_datetime): + 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 build_todo_query(key, todo): + """Build query for ToDo.""" + task = key[CONF_TASK_LIST] + show_completed = task[CONF_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 + + +class O365TodoEntityServices: + """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/docs/calendar_panel.md b/docs/calendar_panel.md index 4e1e17d..61e3e48 100644 --- a/docs/calendar_panel.md +++ b/docs/calendar_panel.md @@ -1,6 +1,6 @@ --- title: Calendar Panel -nav_order: 11 +nav_order: 17 --- Creation and deletion of events is possible via the Calendar Panel introduced in HA 2023.1. This UI allows you to create recurring events, which is not possible via the HA services methods. diff --git a/docs/errors.md b/docs/errors.md index a365efd..eb914af 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -1,6 +1,6 @@ --- title: Errors -nav_order: 12 +nav_order: 19 --- # Errors @@ -26,6 +26,9 @@ nav_order: 12 * **Unable to fetch auth token. Error: (invalid_client) AADSTS7000215: Invalid client secret provided.** * Ensure the configured secret is the client secret __value__, not the client secret ID +* **Token corrupt for account - please delete and re-authenticate.** + * You will need to delete your token and reauthenticate. Please check the [permissions page](./permissions.md) for more details. + **_Please note that any changes made to your Azure app settings takes a few minutes to propagate. Please wait around 5 minutes between changes to your settings and any auth attempts from Home Assistant._** # Installation issues diff --git a/docs/events.md b/docs/events.md index 55ac24a..ba1e059 100644 --- a/docs/events.md +++ b/docs/events.md @@ -1,6 +1,6 @@ --- title: Events -nav_order: 10 +nav_order: 16 --- # Events diff --git a/docs/installation_and_configuration.md b/docs/installation_and_configuration.md index 9b7ac62..fba15b8 100644 --- a/docs/installation_and_configuration.md +++ b/docs/installation_and_configuration.md @@ -105,7 +105,7 @@ Key | Type | Required | Description `query_sensors` | `list` | `False` | List of query_sensor config entries `status_sensors` | `list` | `False` | List of status_sensor config entries. *Not for use on personal accounts or shared mailboxes* `chat_sensors` | `list` | `False` | List of chat_sensor config entries. *Not for use on personal accounts or shared mailboxes* -`todo_sensors` | `object` | `False` | To-Do sensor options *Not for use on shared mailboxes* +`todo_sensors` | `object` | `False` | To-Do List options *Not for use on shared mailboxes* `auto_reply_sensors` | `object` | `False` | Auto-reply sensor options *Not for use on shared mailboxes* `shared_mailbox` | `string` | `False` | Email address or ID of shared mailbox *Only available for calendar and email sensors* @@ -167,7 +167,7 @@ Key | Type | Required | Description Key | Type | Required | Description -- | -- | -- | -- -`enabled` | `boolean` | `True` | True=Enables To-Do sensors, **False**=Disables To-Do sensors. +`enabled` | `boolean` | `True` | True=Enables To-Do Lists, **False**=Disables To-Do Lists. `enable_update` | `boolean` | `False` | If True (**default is False**), this will enable the services to create/update/delete tasks #### auto_reply_sensors diff --git a/docs/legacy_migration.md b/docs/legacy_migration.md index 1d066a7..e69da1e 100644 --- a/docs/legacy_migration.md +++ b/docs/legacy_migration.md @@ -1,6 +1,6 @@ --- title: Legacy Migration -nav_order: 13 +nav_order: 20 --- # Legacy Migration diff --git a/docs/permissions.md b/docs/permissions.md index 71965c2..693ce70 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -15,7 +15,7 @@ Under "API Permissions" click Add a permission, then Microsoft Graph, then Deleg * AutoReply - For Auto reply/Out of Office message configuration - If you intend to send emails use calendar update functionality, then set [enable_update](./installation_and_configuration.md#configuration_variables) at the top level to `true`. For Todo sensors set [enable_update](installation_and_configuration.md#todo_sensors) to true. Then for any sensor type, add the relevant `ReadWrite` permission as denoted by a `Y` in the update column. + If you intend to send emails use calendar update functionality, then set [enable_update](./installation_and_configuration.md#configuration_variables) at the top level to `true`. For To-do List set [enable_update](installation_and_configuration.md#todo_lists) to true. Then for any sensor type, add the relevant `ReadWrite` permission as denoted by a `Y` in the update column. | Feature | Permissions | Update | O365 Description | Notes | diff --git a/docs/sensor.md b/docs/sensor.md index 795438b..042e221 100644 --- a/docs/sensor.md +++ b/docs/sensor.md @@ -33,21 +33,5 @@ Shows the latest chat found on MS Teams. Shows the date and time as the status o The `data` attribute provides an array of chats (max 20), including chat_id and supporting information. Individual array elements can be accessed using the template notation `states.sensor..attributes.data[0...n]`. -## Task/To-Do Sensor - -One sensor is created for each task list on the user account. Each sensor shows the number of incomplete tasks as the status of the sensor. The `all_tasks` attribute is an array of incomplete tasks. The `overdue_tasks` attribute shows any tasks which have a due date and are overdue as an array. - -### Display -In order to show the tasks in the front end, a markdown card can be used. The following is an example that allows you to display a bulleted list subject from the `all_tasks` array of tasks. - -```yaml -type: markdown -title: Tasks -content: |- - {% raw %}{% for task in state_attr('sensor.tasks_sc_personal', 'all_tasks') -%} - - {{ task['subject'] }} - {% endfor %}{% endraw %} -``` - ## Auto Reply Sensor Shows the current auto reply settings for your account. Supports the enabling and disabling of auto reply. Note that all attributes are displayed even if auto reply is disabled for reference purposes. diff --git a/docs/services.md b/docs/services.md index 08b87af..e333f3c 100644 --- a/docs/services.md +++ b/docs/services.md @@ -1,6 +1,6 @@ --- title: Services -nav_order: 9 +nav_order: 15 --- # Services diff --git a/docs/todo.md b/docs/todo.md new file mode 100644 index 0000000..e280733 --- /dev/null +++ b/docs/todo.md @@ -0,0 +1,20 @@ +--- +title: Todo Lists +nav_order: 9 +--- + +# Todo Lists + +One Todo List entity is created for each task list on the user account. Each sensor shows the number of incomplete tasks as the status of the sensor. The `all_tasks` attribute is an array of incomplete tasks. The `overdue_tasks` attribute shows any tasks which have a due date and are overdue as an array. + +### Display +In order to show the tasks in the front end, a markdown card can be used. The following is an example that allows you to display a bulleted list subject from the `all_tasks` array of tasks. + +```yaml +type: markdown +title: Tasks +content: |- + {% raw %}{% for task in state_attr('todo.tasks_sc_personal', 'all_tasks') -%} + - {{ task['subject'] }} + {% endfor %}{% endraw %} +``` \ No newline at end of file diff --git a/hacs.json b/hacs.json index 6855a1f..0ea18d0 100644 --- a/hacs.json +++ b/hacs.json @@ -2,7 +2,7 @@ "name": "Office 365 Integration", "zip_release": true, "filename": "o365.zip", - "homeassistant": "2022.5.0", + "homeassistant": "2023.11.0", "content_in_root": false, "render_readme": true }