├── .python-version ├── tests ├── data │ └── token │ │ ├── corrupt.token │ │ └── corrupt2.token ├── __init__.py ├── helpers │ ├── __init__.py │ ├── mock_config_entry.py │ └── utils.py ├── integration │ ├── data_integration │ │ ├── yaml │ │ │ ├── ms365_calendars_empty.yaml │ │ │ ├── ms365_calendars_corrupt.yaml │ │ │ ├── ms365_calendars_exclude.yaml │ │ │ ├── ms365_calendars_search.yaml │ │ │ ├── ms365_calendars_sensitivity.yaml │ │ │ └── ms365_calendars_base.yaml │ │ ├── O365 │ │ │ ├── discovery.json │ │ │ ├── calendar1_calendar_view_none.json │ │ │ ├── calendar1.json │ │ │ ├── calendar2.json │ │ │ ├── calendar3.json │ │ │ ├── me.json │ │ │ ├── calendars_one.json │ │ │ ├── calendar1_event1.json │ │ │ ├── openid.json │ │ │ ├── calendars.json │ │ │ ├── calendar1_event2.json │ │ │ ├── calendar2_calendar_view.json │ │ │ ├── calendar3_calendar_view.json │ │ │ ├── calendar1_calendar_view.json │ │ │ ├── calendar1_calendar_view_started.json │ │ │ ├── calendar1_calendar_view_all_day.json │ │ │ └── calendar1_calendar_view_not_started.json │ │ └── state │ │ │ └── __init__.py │ ├── helpers_integration │ │ ├── __init__.py │ │ ├── utils_integration.py │ │ └── mocks.py │ ├── __init__.py │ ├── test_filemgmt.py │ ├── test_init.py │ ├── const_integration.py │ ├── fixtures.py │ └── test_config_flow.py ├── const.py ├── test_diagnostic.py ├── test_migration.py ├── test_permissions.py ├── test_init.py └── conftest.py ├── requirements_release.txt ├── custom_components └── ms365_calendar │ ├── classes │ ├── __init__.py │ ├── config_entry.py │ ├── entity.py │ └── permissions.py │ ├── helpers │ ├── __init__.py │ ├── filemgmt.py │ ├── config_entry.py │ └── utils.py │ ├── integration │ ├── __init__.py │ ├── sync │ │ ├── __init__.py │ │ ├── store.py │ │ ├── timeline.py │ │ └── sync.py │ ├── permissions_integration.py │ ├── store_integration.py │ ├── const_integration.py │ ├── filemgmt_integration.py │ ├── setup_integration.py │ ├── utils_integration.py │ ├── schema_integration.py │ └── coordinator_integration.py │ ├── icons.json │ ├── calendar.py │ ├── manifest.json │ ├── diagnostics.py │ ├── schema.py │ ├── const.py │ ├── services.yaml │ └── __init__.py ├── requirements_test.txt ├── requirements.txt ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml ├── workflows │ ├── hassfest.yaml │ ├── hacs.yaml │ ├── sourcery.yaml │ ├── lint.yaml │ ├── test.yaml │ ├── stale.yaml │ ├── ms365release.yaml │ └── codeql.yml └── dependabot.yml ├── setup.cfg ├── hacs.json ├── docs ├── errors.md ├── token.md ├── authentication.md ├── prerequisites.md ├── calendar_panel.md ├── sensor.md ├── index.md ├── _config.yml ├── events.md ├── services.md ├── permissions.md ├── synchronization.md ├── installation_and_configuration.md └── calendar_configuration.md ├── LICENSE ├── README.md └── .gitignore /.python-version: -------------------------------------------------------------------------------- 1 | 3.13.2 2 | -------------------------------------------------------------------------------- /tests/data/token/corrupt.token: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/data/token/corrupt2.token: -------------------------------------------------------------------------------- 1 | corrupt -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for MS365.""" 2 | -------------------------------------------------------------------------------- /tests/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for MS365.""" 2 | -------------------------------------------------------------------------------- /requirements_release.txt: -------------------------------------------------------------------------------- 1 | PyGithub>=1.51 2 | ruff==0.14.4 3 | -------------------------------------------------------------------------------- /tests/integration/data_integration/yaml/ms365_calendars_empty.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/classes/__init__.py: -------------------------------------------------------------------------------- 1 | """Initialise.""" 2 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | """Initialise.""" 2 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/integration/__init__.py: -------------------------------------------------------------------------------- 1 | """Initialise.""" 2 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/integration/sync/__init__.py: -------------------------------------------------------------------------------- 1 | """Initialise.""" 2 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | pytest-homeassistant-custom-component>=0.13 -------------------------------------------------------------------------------- /tests/integration/helpers_integration/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for MS365 Calendar.""" 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | O365>=2.1.4 2 | BeautifulSoup4>=4.10.0 3 | portalocker>=3.1.1 4 | ical>=10.0.0 5 | oauthlib 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ["https://www.buymeacoffee.com/rogtp", "https://www.paypal.com/donate/?hosted_button_id=F7TGHNGH7A526"] 2 | -------------------------------------------------------------------------------- /tests/integration/data_integration/O365/discovery.json: -------------------------------------------------------------------------------- 1 | {"tenant_discovery_endpoint":"https://login.partner.microsoftonline.cn/common/v2.0/.well-known/openid-configuration"} -------------------------------------------------------------------------------- /tests/integration/data_integration/yaml/ms365_calendars_corrupt.yaml: -------------------------------------------------------------------------------- 1 | 2 | - cal_id: calendar1 3 | entities: 4 | - device_id: Calendar1 5 | end_offset: 24 6 | start_offset: 0 7 | track: true 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = tests 3 | norecursedirs = .git 4 | addopts = 5 | --strict-markers 6 | --cov=custom_components 7 | asyncio_mode = auto 8 | asyncio_default_fixture_loop_scope = function -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Microsoft 365 - Calendar", 3 | "zip_release": true, 4 | "filename": "ms365_calendar.zip", 5 | "homeassistant": "2024.12.0", 6 | "content_in_root": false, 7 | "render_readme": true 8 | } 9 | -------------------------------------------------------------------------------- /docs/errors.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Errors 3 | nav_order: 19 4 | --- 5 | 6 | # Errors 7 | 8 | Guidance on logged errors for the MS365 Integrations can be found on the MS365 Home Assistant [Errors](https://rogerselwyn.github.io/MS365-HomeAssistant/errors.html) page. -------------------------------------------------------------------------------- /docs/token.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Token 3 | nav_order: 18 4 | --- 5 | 6 | # Token 7 | 8 | Token management of the MS365 Integrations can be found on the MS365 Home Assistant [Token](https://rogerselwyn.github.io/MS365-HomeAssistant/token.html) page. 9 | 10 | -------------------------------------------------------------------------------- /tests/integration/data_integration/yaml/ms365_calendars_exclude.yaml: -------------------------------------------------------------------------------- 1 | 2 | - cal_id: calendar1 3 | entities: 4 | - device_id: Calendar1 5 | end_offset: 24 6 | name: Calendar1 7 | start_offset: 0 8 | track: true 9 | exclude: 10 | - "event 1" 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Community questions 4 | url: https://community.home-assistant.io/t/office-365-calendar-access 5 | about: If you've got a general question, ask it in the Home Assistant Community forum. 6 | -------------------------------------------------------------------------------- /docs/authentication.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Authentication 3 | nav_order: 5 4 | --- 5 | 6 | # Authentication 7 | 8 | Authentication of the MS365 Integrations can be found on the MS365 Home Assistant [Authentication](https://rogerselwyn.github.io/MS365-HomeAssistant/authentication.html) page. 9 | -------------------------------------------------------------------------------- /docs/prerequisites.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Prerequisites 3 | nav_order: 2 4 | --- 5 | 6 | # Prerequisites 7 | 8 | Prerequistes for installation of the MS365 Integrations can be found on the MS365 Home Assistant [Prerequisites](https://rogerselwyn.github.io/MS365-HomeAssistant/prerequisites.html) page. -------------------------------------------------------------------------------- /tests/integration/data_integration/O365/calendar1_calendar_view_none.json: -------------------------------------------------------------------------------- 1 | { 2 | "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('fake-user-id')/calendars('calendar1')/calendarView(subject,body,attendees,categories,sensitivity,start,seriesMasterId,isAllDay,end,showAs,location)", 3 | "value": [ ] 4 | } -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | hassfest: 11 | name: hassfest Action 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v6" 15 | - uses: home-assistant/actions/hassfest@master 16 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/helpers/filemgmt.py: -------------------------------------------------------------------------------- 1 | """File management processes.""" 2 | 3 | import os 4 | 5 | from ..const import ( 6 | MS365_STORAGE, 7 | ) 8 | 9 | 10 | def build_config_file_path(hass, filepath): 11 | """Create config path.""" 12 | root = hass.config.config_dir 13 | 14 | return os.path.join(root, MS365_STORAGE, filepath) 15 | -------------------------------------------------------------------------------- /.github/workflows/hacs.yaml: -------------------------------------------------------------------------------- 1 | name: HACS Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | hacs: 11 | name: HACS Action 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v6" 15 | - name: HACS validation 16 | uses: "hacs/action@main" 17 | with: 18 | category: "integration" 19 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=unused-import 2 | 3 | """Tests for MS365 Calendar.""" 4 | 5 | from custom_components.ms365_calendar.classes import api # noqa: F401 6 | from custom_components.ms365_calendar.diagnostics import ( 7 | async_get_config_entry_diagnostics, # noqa: F401 8 | ) 9 | from custom_components.ms365_calendar.helpers.config_entry import ( 10 | MS365ConfigEntry, # noqa: F401 11 | ) 12 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "scan_for_calendars": "mdi:calendar-sync", 4 | "scan_for_todo_lists": "mdi:clipboard-list", 5 | "respond_calendar_event": "mdi:calendar-arrow-left", 6 | "create_calendar_event": "mdi:calendar-plus", 7 | "modify_calendar_event": "mdi:calendar-edit", 8 | "remove_calendar_event": "mdi:calendar-remove" 9 | } 10 | } -------------------------------------------------------------------------------- /tests/integration/data_integration/yaml/ms365_calendars_search.yaml: -------------------------------------------------------------------------------- 1 | 2 | - cal_id: calendar1 3 | entities: 4 | - device_id: Calendar1 5 | end_offset: 24 6 | name: Calendar1 7 | start_offset: 0 8 | track: true 9 | search: "event 1" 10 | 11 | - cal_id: group:calendar2 12 | entities: 13 | - device_id: Calendar2 14 | end_offset: 24 15 | name: Calendar2 16 | start_offset: 0 17 | track: false 18 | -------------------------------------------------------------------------------- /.github/workflows/sourcery.yaml: -------------------------------------------------------------------------------- 1 | name: Sourcery 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | review-with-sourcery: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v6 12 | 13 | - uses: actions/setup-python@v6 14 | with: 15 | python-version: '3.13' 16 | 17 | - uses: sourcery-ai/action@v1 18 | with: 19 | token: ${{ secrets.SOURCERY_TOKEN }} 20 | -------------------------------------------------------------------------------- /tests/integration/data_integration/yaml/ms365_calendars_sensitivity.yaml: -------------------------------------------------------------------------------- 1 | 2 | - cal_id: calendar1 3 | entities: 4 | - device_id: Calendar1 5 | end_offset: 24 6 | name: Calendar1 7 | start_offset: 0 8 | track: true 9 | sensitivity_exclude: 10 | - "private" 11 | 12 | - cal_id: group:calendar2 13 | entities: 14 | - device_id: Calendar2 15 | end_offset: 24 16 | name: Calendar2 17 | start_offset: 0 18 | track: false 19 | -------------------------------------------------------------------------------- /docs/calendar_panel.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Calendar Panel 3 | nav_order: 17 4 | --- 5 | 6 | Creation, modification deletion of events is possible via the Calendar Panel. This UI allows you to create recurring events, which is not possible via the HA services methods. 7 | 8 | If you choose to delete 'Delete All Future Events', it will delete the whole series not just future events. This is due to the differences between MS365_Calendar and the iCal specification that the core calendar is built on. -------------------------------------------------------------------------------- /tests/helpers/mock_config_entry.py: -------------------------------------------------------------------------------- 1 | """Mock config entry.""" 2 | 3 | from pytest_homeassistant_custom_component.common import MockConfigEntry 4 | 5 | from ..integration import MS365ConfigEntry 6 | 7 | 8 | class MS365MockConfigEntry(MockConfigEntry): 9 | """Mock config entry with MS365 runtime_data.""" 10 | 11 | def __init__(self, *args, **kwargs) -> None: 12 | """Initialise MS365MockConfigEntry.""" 13 | self.runtime_data: MS365ConfigEntry = None 14 | super().__init__(*args, **kwargs) 15 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/classes/config_entry.py: -------------------------------------------------------------------------------- 1 | """MS365 Config Entry Structure.""" 2 | 3 | from dataclasses import dataclass 4 | from types import MappingProxyType 5 | from typing import Any 6 | 7 | from homeassistant.config_entries import ConfigEntry 8 | 9 | MS365ConfigEntry = ConfigEntry["MS365Data"] 10 | 11 | 12 | @dataclass 13 | class MS365Data: 14 | """Data previously stored in hass.data.""" 15 | 16 | permissions: any 17 | ha_account: any 18 | coordinator: any 19 | sensors: any 20 | options: MappingProxyType[str, Any] 21 | -------------------------------------------------------------------------------- /tests/integration/data_integration/yaml/ms365_calendars_base.yaml: -------------------------------------------------------------------------------- 1 | 2 | - cal_id: calendar1 3 | entities: 4 | - device_id: Calendar1 5 | end_offset: 24 6 | name: Calendar1 7 | start_offset: 0 8 | track: true 9 | 10 | - cal_id: group:calendar2 11 | entities: 12 | - device_id: Calendar2 13 | end_offset: 24 14 | name: Calendar2 15 | start_offset: 0 16 | track: true 17 | 18 | - cal_id: calendar3 19 | entities: 20 | - device_id: Calendar3 21 | end_offset: 24 22 | name: Calendar3 23 | start_offset: 0 24 | track: true 25 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/helpers/config_entry.py: -------------------------------------------------------------------------------- 1 | """MS365 Config Entry Structure.""" 2 | 3 | from dataclasses import dataclass 4 | from types import MappingProxyType 5 | from typing import Any 6 | 7 | from homeassistant.config_entries import ConfigEntry 8 | 9 | MS365ConfigEntry = ConfigEntry["MS365Data"] 10 | 11 | 12 | @dataclass 13 | class MS365Data: 14 | """Data previously stored in hass.data.""" 15 | 16 | permissions: any 17 | account: any 18 | is_authenticated: bool 19 | coordinator: any 20 | sensors: any 21 | options: MappingProxyType[str, Any] 22 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/calendar.py: -------------------------------------------------------------------------------- 1 | """Calendar processing.""" 2 | 3 | from homeassistant.core import HomeAssistant 4 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 5 | 6 | from .classes.config_entry import MS365ConfigEntry 7 | from .integration.calendar_integration import async_integration_setup_entry 8 | 9 | 10 | async def async_setup_entry( 11 | hass: HomeAssistant, # pylint: disable=unused-argument 12 | entry: MS365ConfigEntry, 13 | async_add_entities: AddEntitiesCallback, 14 | ) -> None: 15 | """Set up the MS365 platform.""" 16 | 17 | return await async_integration_setup_entry(hass, entry, async_add_entities) 18 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: "Lint" 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | lint: 11 | name: Lint 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - name: "Checkout the repository" 15 | uses: actions/checkout@v6 16 | 17 | - name: "Set up Python" 18 | uses: actions/setup-python@v6 19 | with: 20 | python-version: "3.13" 21 | cache: "pip" 22 | 23 | - name: "Install requirements" 24 | run: python3 -m pip install -r requirements_release.txt 25 | 26 | - name: "Run" 27 | run: python3 -m ruff check . 28 | -------------------------------------------------------------------------------- /tests/const.py: -------------------------------------------------------------------------------- 1 | """Constants for MS365 testing.""" 2 | 3 | from pathlib import Path 4 | 5 | CLIENT_ID = "1234" 6 | CLIENT_SECRET = "5678" 7 | ENTITY_NAME = "test" 8 | 9 | LEGACY_TOKEN = { 10 | "access_token": "fakelegacytoken", 11 | } 12 | 13 | TOKEN_PARAMS = "code=fake.code&state={0}&session_state=fakesessionstate" 14 | TOKEN_URL_ASSERT = ( 15 | "https://login.microsoftonline.com/common/oauth2/v2.0/" + "authorize?client_id=" 16 | ) 17 | 18 | STORAGE_LOCATION = "storage" 19 | TOKEN_LOCATION = "storage/tokens" 20 | 21 | TEST_DATA_LOCATION = Path(__file__).parent.joinpath("data") 22 | TEST_DATA_INTEGRATION_LOCATION = Path(__file__).parent.joinpath( 23 | "integration/data_integration" 24 | ) 25 | -------------------------------------------------------------------------------- /docs/sensor.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Sensors 3 | nav_order: 8 4 | --- 5 | 6 | # Sensors 7 | ## Calendar Sensor 8 | The status of the calendar sensor indicates (on/off) whether there is an event on at the current time. The `message`, `all_day`, `start_time`, `end_time`, `location`, `description` and `offset_reached` attributes provide details of the current or next event. A non-all-day event is favoured over all_day events. 9 | 10 | The `data` attribute provides an array of events for the period defined by the `start_offset` and `end_offset` in `ms365_calendars_.yaml`. Individual array elements can be accessed using the template notation `states.calendar._calendar.attributes.data[0...n]`. 11 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "ms365_calendar", 3 | "name": "Microsoft 365 - Calendar", 4 | "codeowners": [ 5 | "@RogerSelwyn" 6 | ], 7 | "config_flow": true, 8 | "dependencies": [ 9 | "http" 10 | ], 11 | "documentation": "https://github.com/RogerSelwyn/MS365-Calendar", 12 | "iot_class": "cloud_polling", 13 | "issue_tracker": "https://github.com/RogerSelwyn/MS365-Calendar/issues", 14 | "loggers": [ 15 | "custom_components.ms365_calendar", 16 | "O365" 17 | ], 18 | "requirements": [ 19 | "O365>=2.1.4", 20 | "BeautifulSoup4>=4.10.0", 21 | "portalocker>=3.1.1", 22 | "ical>=10.0.0", 23 | "oauthlib" 24 | ], 25 | "version": "v1.7.2" 26 | } -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Python tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | name: Run tests 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: "Checkout the repository" 12 | uses: actions/checkout@v6 13 | 14 | - name: "Set up Python" 15 | uses: actions/setup-python@v6 16 | with: 17 | python-version: "3.13" 18 | cache: "pip" 19 | 20 | - name: "Install requirements" 21 | run: python3 -m pip install -r requirements_test.txt 22 | 23 | - name: "Run pytest" 24 | uses: pavelzw/pytest-action@v2 25 | with: 26 | emoji: false 27 | verbose: false 28 | job-summary: false 29 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | - package-ecosystem: "github-actions" # See documentation for possible values 13 | directory: "/" # Location of package manifests 14 | schedule: 15 | interval: "weekly" 16 | -------------------------------------------------------------------------------- /tests/integration/data_integration/O365/calendar1.json: -------------------------------------------------------------------------------- 1 | { 2 | "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('fake-user-id')/calendars/$entity", 3 | "id": "calendar1", 4 | "name": "Calendar", 5 | "color": "auto", 6 | "hexColor": "#000000", 7 | "isDefaultCalendar": false, 8 | "changeKey": "changekey1", 9 | "canShare": true, 10 | "canViewPrivateItems": true, 11 | "canEdit": true, 12 | "allowedOnlineMeetingProviders": [ 13 | "teamsForBusiness" 14 | ], 15 | "defaultOnlineMeetingProvider": "teamsForBusiness", 16 | "isTallyingResponses": false, 17 | "isRemovable": true, 18 | "owner": { 19 | "name": "John Doe", 20 | "address": "john@nomail.com" 21 | } 22 | } -------------------------------------------------------------------------------- /tests/integration/data_integration/O365/calendar2.json: -------------------------------------------------------------------------------- 1 | { 2 | "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('fake-user-id')/calendars/$entity", 3 | "id": "group:calendar2", 4 | "name": "Calendar2", 5 | "color": "auto", 6 | "hexColor": "", 7 | "isDefaultCalendar": false, 8 | "changeKey": "changekey2", 9 | "canShare": true, 10 | "canViewPrivateItems": true, 11 | "canEdit": true, 12 | "allowedOnlineMeetingProviders": [ 13 | "teamsForBusiness" 14 | ], 15 | "defaultOnlineMeetingProvider": "teamsForBusiness", 16 | "isTallyingResponses": false, 17 | "isRemovable": true, 18 | "owner": { 19 | "name": "John Doe", 20 | "address": "john@nomail.com" 21 | } 22 | } -------------------------------------------------------------------------------- /tests/integration/data_integration/O365/calendar3.json: -------------------------------------------------------------------------------- 1 | { 2 | "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('fake-user-id')/calendars/$entity", 3 | "id": "calendar3", 4 | "name": "Calendar", 5 | "color": "auto", 6 | "hexColor": "#000000", 7 | "isDefaultCalendar": false, 8 | "changeKey": "changekey1", 9 | "canShare": true, 10 | "canViewPrivateItems": true, 11 | "canEdit": false, 12 | "allowedOnlineMeetingProviders": [ 13 | "teamsForBusiness" 14 | ], 15 | "defaultOnlineMeetingProvider": "teamsForBusiness", 16 | "isTallyingResponses": false, 17 | "isRemovable": true, 18 | "owner": { 19 | "name": "John Doe", 20 | "address": "john@nomail.com" 21 | } 22 | } -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | name: Close inactive issues 2 | on: 3 | schedule: 4 | - cron: "30 1 * * *" 5 | 6 | jobs: 7 | close-issues: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | issues: write 11 | pull-requests: write 12 | steps: 13 | - uses: actions/stale@v10 14 | with: 15 | days-before-issue-stale: 30 16 | days-before-issue-close: 14 17 | stale-issue-label: "stale" 18 | stale-issue-message: "This issue is stale because it has been open for 30 days with no activity." 19 | close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." 20 | days-before-pr-stale: -1 21 | days-before-pr-close: -1 22 | repo-token: ${{ secrets.GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/ms365release.yaml: -------------------------------------------------------------------------------- 1 | name: MS365 Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release_zip_file: 9 | name: Prepare release asset 10 | runs-on: "ubuntu-latest" 11 | steps: 12 | - uses: "actions/checkout@v6" 13 | - name: Release Asset 14 | uses: "rogerselwyn/actions/release-asset@main" 15 | with: 16 | github_token: ${{ secrets.GITHUB_TOKEN }} 17 | component: ms365_calendar 18 | 19 | releasenotes: 20 | name: Prepare release notes 21 | needs: release_zip_file 22 | runs-on: "ubuntu-latest" 23 | steps: 24 | - uses: "actions/checkout@v6" 25 | - name: Release Notes 26 | uses: "rogerselwyn/actions/release-notes@main" 27 | with: 28 | github_token: ${{ secrets.GITHUB_TOKEN }} 29 | -------------------------------------------------------------------------------- /tests/integration/data_integration/O365/me.json: -------------------------------------------------------------------------------- 1 | { 2 | "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users/$entity", 3 | "@microsoft.graph.tips": "This request only returns a subset of the resource's properties. Your app will need to use $select to return non-default properties. To find out what other properties are available for this resource see https://learn.microsoft.com/graph/api/resources/user", 4 | "businessPhones": [ 5 | "07999 123456" 6 | ], 7 | "displayName": "John Doe", 8 | "givenName": "John", 9 | "jobTitle": null, 10 | "mail": "john@nomail.com", 11 | "mobilePhone": "+44 7999123456", 12 | "officeLocation": null, 13 | "preferredLanguage": "en-GB", 14 | "surname": "Doe", 15 | "userPrincipalName": "john@nomail.com", 16 | "id": "fake-user-id" 17 | } -------------------------------------------------------------------------------- /tests/integration/data_integration/O365/calendars_one.json: -------------------------------------------------------------------------------- 1 | { 2 | "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('fake-user-id')/calendars", 3 | "value": [ 4 | { 5 | "id": "calendar1", 6 | "name": "Calendar1", 7 | "color": "lightRed", 8 | "hexColor": "#cf2b36", 9 | "isDefaultCalendar": true, 10 | "changeKey": "changekey1", 11 | "canShare": true, 12 | "canViewPrivateItems": true, 13 | "canEdit": true, 14 | "allowedOnlineMeetingProviders": [ 15 | "teamsForBusiness" 16 | ], 17 | "defaultOnlineMeetingProvider": "teamsForBusiness", 18 | "isTallyingResponses": true, 19 | "isRemovable": false, 20 | "owner": { 21 | "name": "John Doe", 22 | "address": "john@nomail.com" 23 | } 24 | } 25 | ] 26 | } -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Home 3 | nav_order: 1 4 | --- 5 | 6 | # Overview 7 | These are the integrations that make up the full MS365 suite. It is made up of: 8 | * [MS365 Calendar](https://github.com/RogerSelwyn/MS365-Calendar) 9 | * [MS365 Contacts](https://github.com/RogerSelwyn/MS365-Contacts) 10 | * [MS365 Mail](https://github.com/RogerSelwyn/MS365-Mail) 11 | * [MS365-Teams](https://github.com/RogerSelwyn/MS365-Teams) 12 | * [MS365-ToDo](https://github.com/RogerSelwyn/MS365-ToDo) 13 | 14 | All the integrations are built to the same standard with the same authentication and configuration mechanism. All the integrations can use the same Entra ID App Registration and secret if desired. 15 | 16 | The general guidance for all the integrations can be found on the MS365 Home Assistant [Documentation](https://rogerselwyn.github.io/MS365-HomeAssistant/) page. 17 | 18 | # Microsoft 365 Calendar Integration for Home Assistant 19 | 20 | This integration enables: 21 | 1. Getting, creating, updating and responding to calendar events 22 | 23 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | title: MS365 Calendar for Home Assistant 2 | remote_theme: just-the-docs/just-the-docs 3 | 4 | search_enabled: true 5 | search: 6 | heading_level: 3 7 | 8 | # Back to top link 9 | back_to_top: true 10 | back_to_top_text: "Back to top" 11 | 12 | gh_edit_link: true # show or hide edit this page link 13 | gh_edit_link_text: "Edit this page on GitHub" 14 | gh_edit_repository: "https://github.com/RogerSelwyn/MS365-Calendar" # the github URL for your repo 15 | gh_edit_branch: "master" # the branch that your docs is served from 16 | gh_edit_source: docs # the source that your files originate from 17 | gh_edit_view_mode: "tree" # "tree" or "edit" if you want the user to jump into the editor immediately 18 | 19 | # External navigation links 20 | nav_external_links: 21 | - title: General Docs 22 | url: https://rogerselwyn.github.io/MS365-HomeAssistant/ 23 | hide_icon: false # set to true to hide the external link icon - defaults to false 24 | opens_in_new_tab: false # set to true to open this link in a new tab - defaults to false 25 | -------------------------------------------------------------------------------- /tests/test_diagnostic.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=unused-argument, line-too-long 2 | """Test the diagnostics.""" 3 | 4 | from homeassistant.core import HomeAssistant 5 | 6 | from .helpers.mock_config_entry import MS365MockConfigEntry 7 | from .integration import async_get_config_entry_diagnostics 8 | from .integration.const_integration import ( 9 | DIAGNOSTIC_GRANTED_PERMISSIONS, 10 | DIAGNOSTIC_REQUESTED_PERMISSIONS, 11 | ) 12 | 13 | 14 | async def test_diagnostics( 15 | hass: HomeAssistant, 16 | setup_base_integration: None, 17 | base_config_entry: MS365MockConfigEntry, 18 | ): 19 | """Test Diagnostics.""" 20 | result = await async_get_config_entry_diagnostics(hass, base_config_entry) 21 | 22 | assert "config_entry_data" in result 23 | assert result["config_entry_data"]["client_id"] == "**REDACTED**" 24 | assert result["config_entry_data"]["client_secret"] == "**REDACTED**" 25 | assert result["config_granted_permissions"] == DIAGNOSTIC_GRANTED_PERMISSIONS 26 | assert result["config_requested_permissions"] == DIAGNOSTIC_REQUESTED_PERMISSIONS 27 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/diagnostics.py: -------------------------------------------------------------------------------- 1 | """Diagnostics support for HomeLINK.""" 2 | 3 | from __future__ import annotations 4 | 5 | from homeassistant.components.diagnostics import async_redact_data 6 | from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET 7 | from homeassistant.core import HomeAssistant 8 | 9 | from .classes.config_entry import MS365ConfigEntry 10 | from .const import CONF_SHARED_MAILBOX 11 | 12 | TO_REDACT = {CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_SHARED_MAILBOX} 13 | 14 | 15 | async def async_get_config_entry_diagnostics( 16 | hass: HomeAssistant, # pylint: disable=unused-argument 17 | entry: MS365ConfigEntry, 18 | ) -> dict: 19 | """Return diagnostics for a config entry.""" 20 | return { 21 | "config_entry_data": async_redact_data(dict(entry.data), TO_REDACT), 22 | "config_entry_options": dict(entry.runtime_data.options), 23 | "config_granted_permissions": list(entry.runtime_data.permissions.permissions), 24 | "config_requested_permissions": list( 25 | entry.runtime_data.permissions.requested_permissions 26 | ), 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Patrick Toft Steffensen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/schema.py: -------------------------------------------------------------------------------- 1 | """Schema for MS365 Integration.""" 2 | 3 | import homeassistant.helpers.config_validation as cv 4 | import voluptuous as vol 5 | from homeassistant.data_entry_flow import section 6 | 7 | from .const import ( 8 | CONF_ALT_AUTH_METHOD, 9 | CONF_API_COUNTRY, 10 | CONF_API_OPTIONS, 11 | CONF_CLIENT_ID, 12 | CONF_CLIENT_SECRET, 13 | CONF_ENTITY_NAME, 14 | CONF_URL, 15 | CountryOptions, 16 | ) 17 | 18 | CONFIG_SCHEMA = { 19 | vol.Required(CONF_ENTITY_NAME): vol.All(cv.string, vol.Strip), 20 | vol.Required(CONF_CLIENT_ID): vol.All(cv.string, vol.Strip), 21 | vol.Required(CONF_CLIENT_SECRET): vol.All(cv.string, vol.Strip), 22 | vol.Optional(CONF_ALT_AUTH_METHOD, default=False): cv.boolean, 23 | vol.Required(CONF_API_OPTIONS): section( 24 | vol.Schema( 25 | { 26 | vol.Required(CONF_API_COUNTRY, default=CountryOptions.DEFAULT): vol.In( 27 | CountryOptions 28 | ) 29 | } 30 | ), 31 | {"collapsed": True}, 32 | ), 33 | } 34 | 35 | REQUEST_AUTHORIZATION_DEFAULT_SCHEMA = {vol.Required(CONF_URL): cv.string} 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project 3 | title: "[Feature Request]" 4 | labels: Feature Request 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Please complete this form as fully as possible. The more information you supply, the more likely it will be understood and acted upon. 10 | 11 | - type: textarea 12 | attributes: 13 | label: Is your feature request related to a problem? 14 | description: > 15 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 16 | - type: textarea 17 | validations: 18 | required: true 19 | attributes: 20 | label: Describe the solution you'd like. 21 | description: > 22 | A clear and concise description of what you want to happen 23 | - type: textarea 24 | attributes: 25 | label: Describe alternatives you've considered 26 | description: > 27 | A clear and concise description of any alternative solutions or features you've considered. 28 | - type: textarea 29 | attributes: 30 | label: Additional context 31 | description: > 32 | Add any other context or screenshots about the feature request here. 33 | -------------------------------------------------------------------------------- /docs/events.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Events 3 | nav_order: 16 4 | --- 5 | 6 | # Events 7 | 8 | The attribute `ha_event` shows whether the event is triggered by an HA initiated action 9 | 10 | ## Calendar Events 11 | 12 | Events will be raised for the following items. 13 | 14 | - ms365_calendar_create_calendar_event - Creation of a new event via the MS365 Calendar integration 15 | - ms365_calendar_modify_calendar_event - Update of an event via the MS365 Calendar integration 16 | - ms365_calendar_modify_calendar_recurrences - Update of a recurring event via the MS365 Calendar integration 17 | - ms365_calendar_remove_calendar_event - Removal of an event via the MS365 Calendar integration 18 | - ms365_calendar_remove_calendar_recurrences - Removal of a recurring event series via the MS365 Calendar integration 19 | - ms365_calendar_respond_calendar_event - Response to an event via the MS365 Calendar integration 20 | 21 | The events have the following general structure: 22 | 23 | ```yaml 24 | event_type: ms365_calendar_create_calendar_event 25 | data: 26 | event_id: >- 27 | AAMkAGQwYzQ5ZjZjLTQyYmItNDJmNy04NDNjLTJjYWY3NzMyMDBmYwBGAAAAAAC9VxHxYFrdCHSJkXtJ-BwCoiRErLbiNRJDCFyMjq4khAAY9v0_vAACoiRErLbiNRJDCFyMjq4khAAcZSY4SAAA= 28 | ha_event: true 29 | origin: LOCAL 30 | time_fired: "2023-02-19T15:29:01.962020+00:00" 31 | context: 32 | id: 01GSN4NWGABVFQQWPP2D8G3CN8 33 | parent_id: null 34 | user_id: null 35 | ``` 36 | 37 | -------------------------------------------------------------------------------- /docs/services.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Services 3 | nav_order: 15 4 | --- 5 | 6 | # Services 7 | 8 | ## Calendar Services 9 | ### ms365_calendar.create_calendar_event 10 | Create an event in the specified calendar - All parameters are shown in the available parameter list on the Developer Tools/Services tab. 11 | ### ms365_calendar.modify_calendar_event 12 | Modify an event in the specified calendar - All parameters are shown in the available parameter list on the Developer Tools/Services tab. Not possible for group calendars. 13 | ### ms365_calendar.remove_calendar_event 14 | Remove an event in the specified calendar - All parameters are shown in the available parameter list on the Developer Tools/Services tab. Not possible for group calendars. 15 | ### ms365_calendar.respond_calendar_event 16 | Respond to an event in the specified calendar - All parameters are shown in the available parameter list on the Developer Tools/Services tab. Not possible for group calendars. 17 | 18 | #### Example create event service call 19 | 20 | ```yaml 21 | service: ms365_calendar.create_calendar_event 22 | target: 23 | entity_id: 24 | - calendar.user_primary 25 | data: 26 | subject: Clean up the garage 27 | start: 2023-01-01T12:00:00+0000 28 | end: 2023-01-01T12:30:00+0000 29 | body: Remember to also clean out the gutters 30 | location: 1600 Pennsylvania Ave Nw, Washington, DC 20500 31 | sensitivity: Normal 32 | show_as: Busy 33 | attendees: 34 | - email: test@example.com 35 | type: Required 36 | ``` 37 | 38 | -------------------------------------------------------------------------------- /docs/permissions.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Permissions 3 | nav_order: 3 4 | --- 5 | 6 | # Permissions 7 | 8 | This page details the permissions for this integration. General instructions can be found on the MS365 Home Assistant [Permissions](https://rogerselwyn.github.io/MS365-HomeAssistant/permissions.html) page. 9 | 10 | *Note the requirement for `.Shared` permissions for shared mailbox calendars* 11 | 12 | | Feature | Permissions | Update | MS Graph Description | Notes | 13 | |----------|----------------------------|:------:|-------------------------------------------------------|-------| 14 | | All | offline_access | | *Maintain access to data you have given it access to* | | 15 | | All | User.Read | | *Sign in and read user profile* | | 16 | | Calendar | Calendars.ReadBasic | | *Read basic details of user calendars* | Used when `basic_calendar` is set to `true` | 17 | | Calendar | Calendars.Read | | *Read user calendars* | | 18 | | Calendar | Calendars.ReadWrite | Y | *Read and write user calendars* | | 19 | | Calendar | Calendars.Read.Shared | | *Read user and shared calendars* | For shared mailboxes | 20 | | Calendar | Calendars.ReadWrite.Shared | Y | *Read and write user and shared calendars* | For shared mailboxes | 21 | | Group Calendar | Group.Read.All | | *Read all groups* | Not supported in shared mailboxes | 22 | | Group Calendar | Group.ReadWrite.All | Y | *Read and write all groups* | Not supported in shared mailboxes | 23 | 24 | 25 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/helpers/utils.py: -------------------------------------------------------------------------------- 1 | """Utilities processes.""" 2 | 3 | import warnings 4 | 5 | from bs4 import BeautifulSoup, MarkupResemblesLocatorWarning 6 | from homeassistant.helpers.entity import async_generate_entity_id 7 | 8 | from ..const import CONF_API_COUNTRY, CONF_API_OPTIONS, CountryOptions 9 | 10 | warnings.filterwarnings("ignore", category=MarkupResemblesLocatorWarning) 11 | 12 | 13 | def clean_html(html): 14 | """Clean the HTML.""" 15 | soup = BeautifulSoup(html, features="html.parser") 16 | if body := soup.find("body"): 17 | # get text 18 | text = body.get_text() 19 | 20 | # break into lines and remove leading and trailing space on each 21 | lines = (line.strip() for line in text.splitlines()) 22 | # break multi-headlines into a line each 23 | chunks = (phrase.strip() for line in lines for phrase in line.split(" ")) 24 | # drop blank lines 25 | text = "\n".join(chunk for chunk in chunks if chunk) 26 | return text.replace("\xa0", " ") 27 | 28 | return html 29 | 30 | 31 | def add_attribute_to_item(item, user_input, attribute): 32 | """Add an attribute to an item.""" 33 | if user_input.get(attribute) is not None: 34 | item[attribute] = user_input[attribute] 35 | elif attribute in item: 36 | del item[attribute] 37 | 38 | 39 | def build_entity_id(hass, entity_id_format, name): 40 | """Build an entity ID.""" 41 | return async_generate_entity_id( 42 | entity_id_format, 43 | name, 44 | hass=hass, 45 | ) 46 | 47 | 48 | def get_country(entry_data): 49 | """Get the country from entry_data""" 50 | country = CountryOptions.DEFAULT 51 | if entry_data.get(CONF_API_OPTIONS): 52 | country = entry_data[CONF_API_OPTIONS][CONF_API_COUNTRY] 53 | return country 54 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/classes/entity.py: -------------------------------------------------------------------------------- 1 | """Generic MS365 Sensor Entity.""" 2 | 3 | from homeassistant.exceptions import ServiceValidationError 4 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 5 | 6 | from ..const import ATTR_DATA 7 | from ..integration.const_integration import DOMAIN 8 | from .config_entry import MS365ConfigEntry 9 | 10 | 11 | class MS365Entity(CoordinatorEntity): 12 | """MS365 generic Sensor class.""" 13 | 14 | _attr_should_poll = False 15 | _unrecorded_attributes = frozenset((ATTR_DATA,)) 16 | 17 | def __init__( 18 | self, 19 | coordinator, 20 | entry: MS365ConfigEntry, 21 | name, 22 | entity_id, 23 | unique_id, 24 | ): 25 | """Initialise the MS365 Sensor.""" 26 | super().__init__(coordinator) 27 | self._entry = entry 28 | self._name = name 29 | self._entity_id = entity_id 30 | self._unique_id = unique_id 31 | 32 | @property 33 | def name(self): 34 | """Name property.""" 35 | return self._name 36 | 37 | @property 38 | def entity_key(self): 39 | """Entity Key property.""" 40 | return self._entity_id 41 | 42 | @property 43 | def unique_id(self): 44 | """Entity unique id.""" 45 | return self._unique_id 46 | 47 | def _validate_permissions(self, required_permission, required_permission_error): 48 | if not self._entry.runtime_data.permissions.validate_authorization( 49 | required_permission 50 | ): 51 | raise ServiceValidationError( 52 | translation_domain=DOMAIN, 53 | translation_key="not_authorised", 54 | translation_placeholders={ 55 | "required_permission": required_permission_error, 56 | }, 57 | ) 58 | 59 | return True 60 | -------------------------------------------------------------------------------- /tests/integration/data_integration/state/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for MS365 Calendar.""" 2 | 3 | import datetime 4 | 5 | import zoneinfo 6 | 7 | BASE_STATE_CAL1 = [ 8 | { 9 | "summary": "Test event 1 calendar1", 10 | "start": datetime.datetime( 11 | 2020, 1, 1, 0, 0, tzinfo=zoneinfo.ZoneInfo(key="UTC") 12 | ), 13 | "end": datetime.datetime( 14 | 2020, 1, 2, 23, 59, 59, tzinfo=zoneinfo.ZoneInfo(key="UTC") 15 | ), 16 | "all_day": False, 17 | "description": "Test", 18 | "location": "Test Location", 19 | "categories": [], 20 | "sensitivity": "Normal", 21 | "show_as": "Busy", 22 | "reminder": {"minutes": 30, "is_on": True}, 23 | "attendees": [], 24 | "uid": "event1", 25 | }, 26 | { 27 | "summary": "Test event 2 calendar1", 28 | "start": datetime.date(2020, 1, 1), 29 | "end": datetime.date(2020, 1, 2), 30 | "all_day": True, 31 | "description": "Plain Text", 32 | "location": "Test Location", 33 | "categories": [], 34 | "sensitivity": "Private", 35 | "show_as": "Busy", 36 | "reminder": {"minutes": 0, "is_on": False}, 37 | "attendees": [], 38 | "uid": "event2", 39 | }, 40 | ] 41 | 42 | BASE_STATE_CAL2 = [ 43 | { 44 | "summary": "Test event calendar2", 45 | "start": datetime.datetime( 46 | 2020, 1, 1, 0, 0, tzinfo=zoneinfo.ZoneInfo(key="UTC") 47 | ), 48 | "end": datetime.datetime( 49 | 2020, 1, 2, 23, 59, 59, tzinfo=zoneinfo.ZoneInfo(key="UTC") 50 | ), 51 | "all_day": False, 52 | "description": "Test", 53 | "location": "Test Location", 54 | "categories": [], 55 | "sensitivity": "Normal", 56 | "show_as": "Busy", 57 | "reminder": {"minutes": 0, "is_on": False}, 58 | "attendees": [], 59 | "uid": "event1", 60 | } 61 | ] 62 | -------------------------------------------------------------------------------- /tests/integration/test_filemgmt.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=unused-argument,line-too-long,wrong-import-order 2 | """Test file management.""" 3 | 4 | import pytest 5 | from homeassistant.core import HomeAssistant 6 | from requests_mock import Mocker 7 | 8 | from ..helpers.mock_config_entry import MS365MockConfigEntry 9 | from .helpers_integration.mocks import MS365MOCKS 10 | from .helpers_integration.utils_integration import check_yaml_file_contents, yaml_setup 11 | 12 | 13 | async def test_base_filemgmt( 14 | tmp_path, 15 | hass: HomeAssistant, 16 | setup_base_integration, 17 | base_config_entry: MS365MockConfigEntry, 18 | ) -> None: 19 | """Test base file management.""" 20 | 21 | check_yaml_file_contents(tmp_path, "ms365_calendars_base") 22 | 23 | 24 | async def test_empty_file( 25 | tmp_path, 26 | hass: HomeAssistant, 27 | requests_mock: Mocker, 28 | base_token, 29 | base_config_entry: MS365MockConfigEntry, 30 | ) -> None: 31 | """Test for an empty yaml file.""" 32 | MS365MOCKS.standard_mocks(requests_mock) 33 | yaml_setup(tmp_path, "ms365_calendars_empty") 34 | 35 | base_config_entry.add_to_hass(hass) 36 | 37 | await hass.config_entries.async_setup(base_config_entry.entry_id) 38 | await hass.async_block_till_done() 39 | 40 | check_yaml_file_contents(tmp_path, "ms365_calendars_base") 41 | 42 | 43 | async def test_corrupt_file( 44 | tmp_path, 45 | hass: HomeAssistant, 46 | requests_mock: Mocker, 47 | base_token, 48 | base_config_entry: MS365MockConfigEntry, 49 | caplog: pytest.LogCaptureFixture, 50 | ) -> None: 51 | """Test for corrupt yaml content.""" 52 | # logging.disable(logging.WARNING) 53 | MS365MOCKS.standard_mocks(requests_mock) 54 | yaml_setup(tmp_path, "ms365_calendars_corrupt") 55 | 56 | base_config_entry.add_to_hass(hass) 57 | 58 | await hass.config_entries.async_setup(base_config_entry.entry_id) 59 | await hass.async_block_till_done() 60 | 61 | assert "Invalid Data - duplicate entries may be created" in caplog.text 62 | -------------------------------------------------------------------------------- /tests/integration/test_init.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=unused-argument 2 | """Test setup process.""" 3 | 4 | from unittest.mock import patch 5 | 6 | from homeassistant.const import CONF_NAME 7 | from homeassistant.core import HomeAssistant 8 | 9 | from custom_components.ms365_calendar.integration.const_integration import ( 10 | CONF_ADVANCED_OPTIONS, 11 | CONF_CALENDAR_LIST, 12 | CONF_DAYS_BACKWARD, 13 | CONF_DAYS_FORWARD, 14 | CONF_HOURS_BACKWARD_TO_GET, 15 | CONF_HOURS_FORWARD_TO_GET, 16 | CONF_TRACK_NEW_CALENDAR, 17 | CONF_UPDATE_INTERVAL, 18 | DEFAULT_DAYS_BACKWARD, 19 | DEFAULT_DAYS_FORWARD, 20 | DEFAULT_UPDATE_INTERVAL, 21 | ) 22 | 23 | from ..helpers.mock_config_entry import MS365MockConfigEntry 24 | from .const_integration import UPDATE_CALENDAR_LIST 25 | 26 | 27 | async def test_reload( 28 | hass: HomeAssistant, 29 | setup_base_integration, 30 | base_config_entry: MS365MockConfigEntry, 31 | ) -> None: 32 | """Test for reload.""" 33 | 34 | result = await hass.config_entries.options.async_init(base_config_entry.entry_id) 35 | result = await hass.config_entries.options.async_configure( 36 | result["flow_id"], 37 | user_input={ 38 | CONF_TRACK_NEW_CALENDAR: True, 39 | CONF_CALENDAR_LIST: UPDATE_CALENDAR_LIST, 40 | CONF_ADVANCED_OPTIONS: { 41 | CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, 42 | CONF_DAYS_BACKWARD: DEFAULT_DAYS_BACKWARD, 43 | CONF_DAYS_FORWARD: DEFAULT_DAYS_FORWARD, 44 | }, 45 | }, 46 | ) 47 | with patch( 48 | "homeassistant.config_entries.ConfigEntries.async_reload" 49 | ) as mock_async_reload: 50 | await hass.config_entries.options.async_configure( 51 | result["flow_id"], 52 | user_input={ 53 | CONF_NAME: "Calendar1", 54 | CONF_HOURS_FORWARD_TO_GET: 24, 55 | CONF_HOURS_BACKWARD_TO_GET: 0, 56 | }, 57 | ) 58 | assert mock_async_reload.called 59 | -------------------------------------------------------------------------------- /docs/synchronization.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Synchronization 3 | nav_order: 7 4 | --- 5 | 6 | # Synchronization 7 | The calendar integration supports two sets of time periods for synchronization. 8 | 9 | 1. Within the configuration options for the integration under [Advanced options](./installation_and_configuration.md#advanced-options), are the master synchronization options for the integration. These settings define what set of events will be retrieved and stored for use by all aspects if Home Assistant usage (e.g. calendar pane or other calendar card). Accessing events within this range will not incur extra data retrieval commitments. Accessing data outside this range will require extra calls to the MS Graph API. The range should not be set to smaller than that defined by item 2, but the integration will ensure that the minimum retrieved is that defined in item 2. The range is configured in **days**. The interval is configured in **seconds**. 10 | 11 | 1. Within the [calendar configuration](./calendar_configuration.md) is the start and end offsets for events that are added to the attributes of the calendar entity. The range is configured in **hours**. 12 | 13 | There is a balance to be made between how much data is retrieved at one time and performance of the Home Assistant UI. Previous to v1.5.0, the only event data retrieved on a scheduled basis was that defined in 2 above, which was done on an every 30 second basis. For people using other functionality, such as the calendar pane, this meant that any events needing to be displayed would be retrieved dynamically every time with no caching. With many calendars in use, performance could be poor. 14 | 15 | If you have many calendars or many events, you may wish to synchronize less frequently, with the knowledge that events created outside HA would not be displayed until the next scheduled synchronization. If you are regularly displaying events from a wide range of dates, you may wish to increase the scheduled retrieval range, to reduce dynamic load time. If you only want to use a small range displayed in the entity attributes and never use anything else, then you can configure accordingly. 16 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/integration/sync/store.py: -------------------------------------------------------------------------------- 1 | """Library for local storage of calendar data. Direct copy of gcal_sync.store.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from abc import ABC 7 | from typing import Any 8 | 9 | __all__ = [ 10 | "CalendarStore", 11 | ] 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | class CalendarStore(ABC): 17 | """Interface for external calendar storage. 18 | 19 | This is an abstract class that may be implemented by callers to provide a 20 | custom implementation for storing the calendar database. 21 | """ 22 | 23 | async def async_load(self) -> dict[str, Any] | None: 24 | """Load data.""" 25 | 26 | async def async_save(self, data: dict[str, Any]) -> None: 27 | """Save data.""" 28 | 29 | 30 | # class InMemoryCalendarStore(CalendarStore): 31 | # """An in memory implementation of CalendarStore.""" 32 | 33 | # def __init__(self) -> None: 34 | # self._data: dict[str, Any] | None = None 35 | 36 | # async def async_load(self) -> dict[str, Any] | None: 37 | # """Load data.""" 38 | # return self._data 39 | 40 | # async def async_save(self, data: dict[str, Any]) -> None: 41 | # """Save data.""" 42 | # self._data = data 43 | 44 | 45 | class ScopedCalendarStore(CalendarStore): 46 | """A store that reads/writes to a key within the store.""" 47 | 48 | def __init__(self, store: CalendarStore, key: str) -> None: 49 | """Initialize ScopedCalendarStore.""" 50 | self._store = store 51 | self._key = key 52 | 53 | async def async_load(self) -> dict[str, Any]: 54 | """Load data from the store.""" 55 | 56 | store_data = await self._store.async_load() or {} 57 | return store_data.get(self._key, {}) # type: ignore[no-any-return] 58 | 59 | async def async_save(self, data: dict[str, Any]) -> None: 60 | """Save data to the store, performing a read/modify/write""" 61 | 62 | store_data = await self._store.async_load() or {} 63 | store_data[self._key] = data 64 | return await self._store.async_save(store_data) 65 | -------------------------------------------------------------------------------- /tests/test_migration.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=unused-argument 2 | """Test migration""" 3 | 4 | import pytest 5 | from homeassistant import config_entries 6 | from homeassistant.core import HomeAssistant 7 | from requests_mock import Mocker 8 | 9 | from .const import ENTITY_NAME 10 | from .helpers.mock_config_entry import MS365MockConfigEntry 11 | from .helpers.utils import mock_token 12 | from .integration.const_integration import ( 13 | BASE_TOKEN_PERMS, 14 | DOMAIN, 15 | MIGRATION_CONFIG_ENTRY, 16 | ) 17 | from .integration.helpers_integration.mocks import MS365MOCKS 18 | 19 | 20 | async def test_default_flow( 21 | tmp_path, 22 | hass: HomeAssistant, 23 | requests_mock: Mocker, 24 | caplog: pytest.LogCaptureFixture, 25 | ) -> None: 26 | """Test the default config_flow.""" 27 | mock_token(requests_mock, BASE_TOKEN_PERMS) 28 | MS365MOCKS.standard_mocks(requests_mock) 29 | 30 | await hass.config_entries.flow.async_init( 31 | DOMAIN, 32 | context={"source": config_entries.SOURCE_IMPORT}, 33 | data=MIGRATION_CONFIG_ENTRY, 34 | ) 35 | assert ( 36 | f"Could not locate token at {tmp_path}/storage/tokens/{DOMAIN}_test.token" 37 | in caplog.text 38 | ) 39 | 40 | 41 | async def test_duplicate_migration( 42 | hass: HomeAssistant, 43 | setup_base_integration, 44 | caplog: pytest.LogCaptureFixture, 45 | ): 46 | """Test duplicate import.""" 47 | 48 | await hass.config_entries.flow.async_init( 49 | DOMAIN, 50 | context={"source": config_entries.SOURCE_IMPORT}, 51 | data=MIGRATION_CONFIG_ENTRY, 52 | ) 53 | assert f"Entry already imported for '{DOMAIN}' - '{ENTITY_NAME}'" in caplog.text 54 | 55 | 56 | async def test_migrate_v1_v2( 57 | tmp_path, 58 | hass: HomeAssistant, 59 | v1_config_entry: MS365MockConfigEntry, 60 | legacy_token, 61 | requests_mock: Mocker, 62 | caplog: pytest.LogCaptureFixture, 63 | ): 64 | """Test v1 migrate.""" 65 | 66 | v1_config_entry.add_to_hass(hass) 67 | await hass.config_entries.async_setup(v1_config_entry.entry_id) 68 | assert f"Token {DOMAIN}_{ENTITY_NAME}.token has been deleted" in caplog.text 69 | assert "Could not locate token at" in caplog.text 70 | -------------------------------------------------------------------------------- /tests/integration/data_integration/O365/calendar1_event1.json: -------------------------------------------------------------------------------- 1 | { 2 | "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('fake-user-id')/calendars", 3 | "@odata.etag": "W/\"qIkRKy24jUSQwhcjI6uJIQAIopRuag==\"", 4 | "id": "event1", 5 | "categories": [], 6 | "reminderMinutesBeforeStart": 0, 7 | "isReminderOn": false, 8 | "subject": "Test event calendar1", 9 | "sensitivity": "normal", 10 | "isAllDay": false, 11 | "seriesMasterId": null, 12 | "showAs": "busy", 13 | "body": { 14 | "contentType": "html", 15 | "content": "\r\n\r\n\r\n\r\n\r\n\r\n\r\n
\r\n
\r\n

 Test

\r\n
\r\n
\r\n\r\n\r\n" 16 | }, 17 | "start": { 18 | "dateTime": "2020-01-01T00:00:00.0000000", 19 | "timeZone": "UTC" 20 | }, 21 | "end": { 22 | "dateTime": "2020-01-02T23:59:59.0000000", 23 | "timeZone": "UTC" 24 | }, 25 | "location": { 26 | "displayName": "Test Location", 27 | "locationUri": "", 28 | "locationType": "default", 29 | "uniqueId": "Test Location", 30 | "uniqueIdType": "private", 31 | "address": { 32 | "street": "", 33 | "city": "", 34 | "state": "", 35 | "countryOrRegion": "", 36 | "postalCode": "" 37 | }, 38 | "coordinates": { 39 | "latitude": 0, 40 | "longitude": 0 41 | } 42 | }, 43 | "attendees": [] 44 | } -------------------------------------------------------------------------------- /tests/integration/data_integration/O365/openid.json: -------------------------------------------------------------------------------- 1 | { 2 | "token_endpoint":"https://login.microsoftonline.com/common/oauth2/v2.0/token", 3 | "token_endpoint_auth_methods_supported":[ 4 | "client_secret_post", 5 | "private_key_jwt", 6 | "client_secret_basic" 7 | ], 8 | "jwks_uri":"https://login.microsoftonline.com/common/discovery/v2.0/keys", 9 | "response_modes_supported":[ 10 | "query", 11 | "fragment", 12 | "form_post" 13 | ], 14 | "subject_types_supported":[ 15 | "pairwise" 16 | ], 17 | "id_token_signing_alg_values_supported":[ 18 | "RS256" 19 | ], 20 | "response_types_supported":[ 21 | "code", 22 | "id_token", 23 | "code id_token", 24 | "id_token token" 25 | ], 26 | "scopes_supported":[ 27 | "openid", 28 | "profile", 29 | "email", 30 | "offline_access" 31 | ], 32 | "issuer":"https://login.microsoftonline.com/{tenantid}/v2.0", 33 | "request_uri_parameter_supported":false, 34 | "userinfo_endpoint":"https://graph.microsoft.com/oidc/userinfo", 35 | "authorization_endpoint":"https://login.microsoftonline.com/common/oauth2/v2.0/authorize", 36 | "device_authorization_endpoint":"https://login.microsoftonline.com/common/oauth2/v2.0/devicecode", 37 | "http_logout_supported":true, 38 | "frontchannel_logout_supported":true, 39 | "end_session_endpoint":"https://login.microsoftonline.com/common/oauth2/v2.0/logout", 40 | "claims_supported":[ 41 | "sub", 42 | "iss", 43 | "cloud_instance_name", 44 | "cloud_instance_host_name", 45 | "cloud_graph_host_name", 46 | "msgraph_host", 47 | "aud", 48 | "exp", 49 | "iat", 50 | "auth_time", 51 | "acr", 52 | "nonce", 53 | "preferred_username", 54 | "name", 55 | "tid", 56 | "ver", 57 | "at_hash", 58 | "c_hash", 59 | "email" 60 | ], 61 | "kerberos_endpoint":"https://login.microsoftonline.com/common/kerberos", 62 | "tenant_region_scope":null, 63 | "cloud_instance_name":"microsoftonline.com", 64 | "cloud_graph_host_name":"graph.windows.net", 65 | "msgraph_host":"graph.microsoft.com", 66 | "rbac_url":"https://pas.windows.net" 67 | } -------------------------------------------------------------------------------- /tests/integration/data_integration/O365/calendars.json: -------------------------------------------------------------------------------- 1 | { 2 | "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('fake-user-id')/calendars", 3 | "value": [ 4 | { 5 | "id": "calendar1", 6 | "name": "Calendar1", 7 | "color": "lightRed", 8 | "hexColor": "#cf2b36", 9 | "isDefaultCalendar": true, 10 | "changeKey": "changekey1", 11 | "canShare": true, 12 | "canViewPrivateItems": true, 13 | "canEdit": true, 14 | "allowedOnlineMeetingProviders": [ 15 | "teamsForBusiness" 16 | ], 17 | "defaultOnlineMeetingProvider": "teamsForBusiness", 18 | "isTallyingResponses": true, 19 | "isRemovable": false, 20 | "owner": { 21 | "name": "John Doe", 22 | "address": "john@nomail.com" 23 | } 24 | }, 25 | { 26 | "id": "group:calendar2", 27 | "name": "Calendar2", 28 | "color": "auto", 29 | "hexColor": "", 30 | "isDefaultCalendar": false, 31 | "changeKey": "changekey2", 32 | "canShare": true, 33 | "canViewPrivateItems": true, 34 | "canEdit": true, 35 | "allowedOnlineMeetingProviders": [ 36 | "teamsForBusiness" 37 | ], 38 | "defaultOnlineMeetingProvider": "teamsForBusiness", 39 | "isTallyingResponses": false, 40 | "isRemovable": true, 41 | "owner": { 42 | "name": "John Doe", 43 | "address": "john@nomail.com" 44 | } 45 | }, 46 | { 47 | "id": "calendar3", 48 | "name": "Calendar3", 49 | "color": "auto", 50 | "hexColor": "", 51 | "isDefaultCalendar": true, 52 | "changeKey": "changekey3", 53 | "canShare": true, 54 | "canViewPrivateItems": true, 55 | "canEdit": false, 56 | "allowedOnlineMeetingProviders": [ 57 | "teamsForBusiness" 58 | ], 59 | "defaultOnlineMeetingProvider": "teamsForBusiness", 60 | "isTallyingResponses": true, 61 | "isRemovable": false, 62 | "owner": { 63 | "name": "John Doe", 64 | "address": "john@nomail.com" 65 | } 66 | } 67 | ] 68 | } -------------------------------------------------------------------------------- /tests/test_permissions.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=unused-argument, line-too-long 2 | """Test permission handling.""" 3 | 4 | import pytest 5 | from homeassistant.core import HomeAssistant 6 | from homeassistant.helpers import issue_registry as ir 7 | 8 | from .helpers.mock_config_entry import MS365MockConfigEntry 9 | from .helpers.utils import token_setup 10 | from .integration.const_integration import DOMAIN, ENTITY_NAME 11 | 12 | 13 | async def test_no_token( 14 | hass: HomeAssistant, 15 | caplog: pytest.LogCaptureFixture, 16 | base_config_entry: MS365MockConfigEntry, 17 | issue_registry: ir.IssueRegistry, 18 | ) -> None: 19 | """Test no token.""" 20 | base_config_entry.add_to_hass(hass) 21 | 22 | await hass.config_entries.async_setup(base_config_entry.entry_id) 23 | await hass.async_block_till_done() 24 | 25 | assert "Could not locate token" in caplog.text 26 | assert len(issue_registry.issues) == 1 27 | 28 | 29 | async def test_corrupt_token( 30 | tmp_path, 31 | hass: HomeAssistant, 32 | caplog: pytest.LogCaptureFixture, 33 | base_config_entry: MS365MockConfigEntry, 34 | issue_registry: ir.IssueRegistry, 35 | ) -> None: 36 | """Fixture for setting up the component.""" 37 | token_setup(tmp_path, "corrupt") 38 | base_config_entry.add_to_hass(hass) 39 | 40 | await hass.config_entries.async_setup(base_config_entry.entry_id) 41 | await hass.async_block_till_done() 42 | 43 | assert ( 44 | f"Token file corrupted for integration '{DOMAIN}', unique identifier '{ENTITY_NAME}', please delete token, re-configure and re-authenticate - No permissions" 45 | in caplog.text 46 | ) 47 | assert len(issue_registry.issues) == 1 48 | 49 | 50 | async def test_corrupt_token2( 51 | tmp_path, 52 | hass: HomeAssistant, 53 | caplog: pytest.LogCaptureFixture, 54 | base_config_entry: MS365MockConfigEntry, 55 | issue_registry: ir.IssueRegistry, 56 | ) -> None: 57 | """Fixture for setting up the component.""" 58 | token_setup(tmp_path, "corrupt2") 59 | base_config_entry.add_to_hass(hass) 60 | 61 | await hass.config_entries.async_setup(base_config_entry.entry_id) 62 | await hass.async_block_till_done() 63 | 64 | assert ( 65 | f"Token file corrupted for integration '{DOMAIN}', unique identifier '{ENTITY_NAME}', please delete token, re-configure and re-authenticate - Expecting value: line 1 column 1 (char 0)" 66 | in caplog.text 67 | ) 68 | assert len(issue_registry.issues) == 1 69 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/integration/sync/timeline.py: -------------------------------------------------------------------------------- 1 | """A Timeline is a set of events on a calendar.""" 2 | 3 | from collections.abc import Generator, Iterable 4 | from datetime import datetime 5 | 6 | from homeassistant.util import dt as dt_util 7 | from ical.iter import ( 8 | MergedIterable, 9 | SortableItem, 10 | SortableItemTimeline, 11 | SortableItemValue, 12 | SortedItemIterable, 13 | ) 14 | from ical.timespan import Timespan 15 | from O365.calendar import Event # pylint: disable=no-name-in-module) 16 | 17 | 18 | class MS365Timeline(SortableItemTimeline[Event]): 19 | """A set of events on a calendar. 20 | A timeline is created by the local sync API and not instantiated directly. 21 | """ 22 | 23 | # def __init__(self, iterable: Iterable[SortableItem[Timespan, Event]]) -> None: 24 | # super().__init__(iterable) 25 | 26 | 27 | def timespan_of(event: Event) -> Timespan: 28 | """Return a timespan representing the event start and end.""" 29 | # if tzinfo is None: 30 | # tzinfo = dt_util.UTC 31 | # return Timespan.of( 32 | # normalize(event.start, tzinfo), 33 | # normalize(event.end, tzinfo), 34 | # ) 35 | if event.is_all_day: 36 | return Timespan.of( 37 | dt_util.start_of_local_day(event.start), 38 | dt_util.start_of_local_day(event.end), 39 | ) 40 | return Timespan.of(event.start, event.end) 41 | 42 | 43 | def calendar_timeline(events: list[Event], tzinfo: datetime.tzinfo) -> MS365Timeline: 44 | """Create a timeline for events on a calendar, including recurrence.""" 45 | normal_events: list[Event] = [] 46 | for event in events: 47 | normal_events.append(event) 48 | 49 | def sortable_items() -> Generator[SortableItem[Timespan, Event], None, None]: 50 | nonlocal normal_events 51 | for event in normal_events: 52 | yield SortableItemValue(timespan_of(event), event) 53 | 54 | iters: list[Iterable[SortableItem[Timespan, Event]]] = [] 55 | iters.append(SortedItemIterable(sortable_items, tzinfo)) 56 | 57 | return MS365Timeline(MergedIterable(iters)) 58 | 59 | 60 | # def normalize(date, tzinfo: datetime.tzinfo) -> datetime: 61 | # """Convert date or datetime to a value that can be used for comparison.""" 62 | # value = date 63 | # if not isinstance(value, datetime): 64 | # value = datetime.combine(value, time.min) 65 | # if value.tzinfo is None: 66 | # value = value.replace(tzinfo=(tzinfo if tzinfo else dt_util.UTC)) 67 | # return value 68 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/integration/permissions_integration.py: -------------------------------------------------------------------------------- 1 | """Permissions processes for calendar.""" 2 | 3 | import logging 4 | from copy import deepcopy 5 | 6 | from ..classes.permissions import BasePermissions 7 | from ..const import ( 8 | CONF_ENABLE_UPDATE, 9 | CONF_ENTITY_NAME, 10 | CONF_SHARED_MAILBOX, 11 | PERM_BASE_PERMISSIONS, 12 | PERM_SHARED, 13 | ) 14 | from .const_integration import ( 15 | CONF_BASIC_CALENDAR, 16 | CONF_GROUPS, 17 | PERM_CALENDARS_READ, 18 | PERM_CALENDARS_READBASIC, 19 | PERM_CALENDARS_READWRITE, 20 | PERM_GROUP_READ_ALL, 21 | PERM_GROUP_READWRITE_ALL, 22 | ) 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | 27 | class Permissions(BasePermissions): 28 | """Class in support of building permission sets.""" 29 | 30 | def __init__(self, hass, config, token_backend): 31 | """Initialise the class.""" 32 | super().__init__(hass, config, token_backend) 33 | 34 | self._shared = PERM_SHARED if config.get(CONF_SHARED_MAILBOX) else "" 35 | self._enable_update = self._config.get(CONF_ENABLE_UPDATE, False) 36 | 37 | @property 38 | def requested_permissions(self): 39 | """Return the required scope.""" 40 | if not self._requested_permissions: 41 | self._requested_permissions = deepcopy(PERM_BASE_PERMISSIONS) 42 | self._build_calendar_permissions() 43 | self._build_group_permissions() 44 | 45 | return self._requested_permissions 46 | 47 | def _build_calendar_permissions(self): 48 | if self._config.get(CONF_BASIC_CALENDAR, False): 49 | if self._enable_update: 50 | _LOGGER.warning( 51 | "'enable_update' should not be true when 'basic_calendar' is true ." 52 | + "for account: %s ReadBasic used. ", 53 | self._config[CONF_ENTITY_NAME], 54 | ) 55 | self._requested_permissions.append(PERM_CALENDARS_READBASIC + self._shared) 56 | elif self._enable_update: 57 | self._requested_permissions.append(PERM_CALENDARS_READWRITE + self._shared) 58 | 59 | else: 60 | self._requested_permissions.append(PERM_CALENDARS_READ + self._shared) 61 | 62 | def _build_group_permissions(self): 63 | if self._config.get(CONF_GROUPS, False): 64 | if self._enable_update: 65 | self._requested_permissions.append(PERM_GROUP_READWRITE_ALL) 66 | else: 67 | self._requested_permissions.append(PERM_GROUP_READ_ALL) 68 | -------------------------------------------------------------------------------- /tests/integration/data_integration/O365/calendar1_event2.json: -------------------------------------------------------------------------------- 1 | { 2 | "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('fake-user-id')/calendars", 3 | "@odata.etag": "W/\"qIkRKy24jUSQwhcjI6uJIQAIopRuag==\"", 4 | "id": "event2", 5 | "categories": [], 6 | "reminderMinutesBeforeStart": 0, 7 | "isReminderOn": false, 8 | "subject": "Test event 2 calendar1", 9 | "sensitivity": "private", 10 | "isAllDay": true, 11 | "seriesMasterId": "master2", 12 | "showAs": "busy", 13 | "body": { 14 | "contentType": "html", 15 | "content": "\r\n\r\n\r\n\r\n\r\n\r\n\r\n
\r\n
\r\n

 Test

\r\n
\r\n
\r\n\r\n\r\n" 16 | }, 17 | "start": { 18 | "dateTime": "2022-10-24T00:00:00.0000000", 19 | "timeZone": "UTC" 20 | }, 21 | "end": { 22 | "dateTime": "2023-10-25T00:00:00.0000000", 23 | "timeZone": "UTC" 24 | }, 25 | "recurrence": { 26 | "pattern": { 27 | "type": "daily", 28 | "interval": 1, 29 | "month": 0, 30 | "dayOfMonth": 0, 31 | "firstDayOfWeek": "sunday", 32 | "index": "first" 33 | }, 34 | "range": { 35 | "type": "endDate", 36 | "startDate": "2022-10-24", 37 | "endDate": "2023-04-24", 38 | "recurrenceTimeZone": "GMT Standard Time", 39 | "numberOfOccurrences": 0 40 | } 41 | }, 42 | "location": { 43 | "displayName": "Test Location", 44 | "locationUri": "", 45 | "locationType": "default", 46 | "uniqueId": "Test Location", 47 | "uniqueIdType": "private", 48 | "address": { 49 | "street": "", 50 | "city": "", 51 | "state": "", 52 | "countryOrRegion": "", 53 | "postalCode": "" 54 | }, 55 | "coordinates": { 56 | "latitude": 0, 57 | "longitude": 0 58 | } 59 | }, 60 | "attendees": [] 61 | } -------------------------------------------------------------------------------- /tests/integration/helpers_integration/utils_integration.py: -------------------------------------------------------------------------------- 1 | """Utilities for MS365 testing.""" 2 | 3 | import shutil 4 | 5 | from homeassistant.const import CONF_NAME 6 | from homeassistant.core import HomeAssistant 7 | 8 | from custom_components.ms365_calendar.integration.const_integration import ( 9 | CONF_ADVANCED_OPTIONS, 10 | CONF_CALENDAR_LIST, 11 | CONF_DAYS_BACKWARD, 12 | CONF_DAYS_FORWARD, 13 | CONF_HOURS_BACKWARD_TO_GET, 14 | CONF_HOURS_FORWARD_TO_GET, 15 | CONF_MAX_RESULTS, 16 | CONF_TRACK_NEW_CALENDAR, 17 | CONF_UPDATE_INTERVAL, 18 | DEFAULT_DAYS_BACKWARD, 19 | DEFAULT_DAYS_FORWARD, 20 | DEFAULT_UPDATE_INTERVAL, 21 | DOMAIN, 22 | ) 23 | 24 | from ...const import STORAGE_LOCATION, TEST_DATA_INTEGRATION_LOCATION 25 | from ...helpers.mock_config_entry import MS365MockConfigEntry 26 | from ..const_integration import UPDATE_CALENDAR_LIST 27 | 28 | 29 | def yaml_setup(tmp_path, infile): 30 | """Setup a yaml file""" 31 | fromfile = TEST_DATA_INTEGRATION_LOCATION / f"yaml/{infile}.yaml" 32 | tofile = tmp_path / STORAGE_LOCATION / f"{DOMAIN}s_test.yaml" 33 | shutil.copy(fromfile, tofile) 34 | 35 | 36 | async def update_options( 37 | hass: HomeAssistant, 38 | base_config_entry: MS365MockConfigEntry, 39 | ) -> None: 40 | """Test the options flow""" 41 | 42 | result = await hass.config_entries.options.async_init(base_config_entry.entry_id) 43 | result = await hass.config_entries.options.async_configure( 44 | result["flow_id"], 45 | user_input={ 46 | CONF_TRACK_NEW_CALENDAR: False, 47 | CONF_CALENDAR_LIST: UPDATE_CALENDAR_LIST, 48 | CONF_ADVANCED_OPTIONS: { 49 | CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, 50 | CONF_DAYS_BACKWARD: DEFAULT_DAYS_BACKWARD, 51 | CONF_DAYS_FORWARD: DEFAULT_DAYS_FORWARD, 52 | }, 53 | }, 54 | ) 55 | result = await hass.config_entries.options.async_configure( 56 | result["flow_id"], 57 | user_input={ 58 | CONF_NAME: "Calendar1_Changed", 59 | CONF_HOURS_FORWARD_TO_GET: 48, 60 | CONF_HOURS_BACKWARD_TO_GET: -48, 61 | CONF_MAX_RESULTS: 5, 62 | }, 63 | ) 64 | 65 | 66 | def check_yaml_file_contents(tmp_path, filename): 67 | """Check contents are what is expected.""" 68 | path = tmp_path / STORAGE_LOCATION / f"{DOMAIN}s_test.yaml" 69 | with open(path, encoding="utf8") as file: 70 | created_yaml = file.read() 71 | path = TEST_DATA_INTEGRATION_LOCATION / f"yaml/{filename}.yaml" 72 | with open(path, encoding="utf8") as file: 73 | compare_yaml = file.read() 74 | assert created_yaml == compare_yaml 75 | -------------------------------------------------------------------------------- /tests/integration/data_integration/O365/calendar2_calendar_view.json: -------------------------------------------------------------------------------- 1 | { 2 | "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('fake-user-id')/calendars('calendar2')/calendarView(subject,body,attendees,categories,sensitivity,start,seriesMasterId,isAllDay,end,showAs,location)", 3 | "value": [ 4 | { 5 | "@odata.etag": "W/\"qIkRKy24jUSQwhcjI6uJIQAIopRuag==\"", 6 | "id": "event1", 7 | "categories": [], 8 | "reminderMinutesBeforeStart": 0, 9 | "isReminderOn": false, 10 | "subject": "Test event calendar2", 11 | "sensitivity": "normal", 12 | "isAllDay": false, 13 | "seriesMasterId": null, 14 | "showAs": "busy", 15 | "body": { 16 | "contentType": "html", 17 | "content": "\r\n\r\n\r\n\r\n\r\n\r\n\r\n
\r\n
\r\n

 Test

\r\n
\r\n
\r\n\r\n\r\n" 18 | }, 19 | "start": { 20 | "dateTime": "2020-01-01T00:00:00.0000000", 21 | "timeZone": "UTC" 22 | }, 23 | "end": { 24 | "dateTime": "2020-01-02T23:59:59.0000000", 25 | "timeZone": "UTC" 26 | }, 27 | "location": { 28 | "displayName": "Test Location", 29 | "locationUri": "", 30 | "locationType": "default", 31 | "uniqueId": "Test Location", 32 | "uniqueIdType": "private", 33 | "address": { 34 | "street": "", 35 | "city": "", 36 | "state": "", 37 | "countryOrRegion": "", 38 | "postalCode": "" 39 | }, 40 | "coordinates": { 41 | "latitude": 0, 42 | "longitude": 0 43 | } 44 | }, 45 | "attendees": [] 46 | } 47 | ] 48 | } -------------------------------------------------------------------------------- /tests/integration/data_integration/O365/calendar3_calendar_view.json: -------------------------------------------------------------------------------- 1 | { 2 | "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('fake-user-id')/calendars('calendar3')/calendarView(subject,body,attendees,categories,sensitivity,start,seriesMasterId,isAllDay,end,showAs,location)", 3 | "value": [ 4 | { 5 | "@odata.etag": "W/\"qIkRKy24jUSQwhcjI6uJIQAIopRuag==\"", 6 | "id": "event1", 7 | "categories": [], 8 | "reminderMinutesBeforeStart": 0, 9 | "isReminderOn": false, 10 | "subject": "Test event 1 calendar3", 11 | "sensitivity": "normal", 12 | "isAllDay": false, 13 | "seriesMasterId": null, 14 | "showAs": "busy", 15 | "body": { 16 | "contentType": "html", 17 | "content": "\r\n\r\n\r\n\r\n\r\n\r\n\r\n
\r\n
\r\n

 Test

\r\n
\r\n
\r\n\r\n\r\n" 18 | }, 19 | "start": { 20 | "dateTime": "2020-01-01T00:00:00.0000000", 21 | "timeZone": "UTC" 22 | }, 23 | "end": { 24 | "dateTime": "2020-01-02T23:59:59.0000000", 25 | "timeZone": "UTC" 26 | }, 27 | "location": { 28 | "displayName": "Test Location", 29 | "locationUri": "", 30 | "locationType": "default", 31 | "uniqueId": "Test Location", 32 | "uniqueIdType": "private", 33 | "address": { 34 | "street": "", 35 | "city": "", 36 | "state": "", 37 | "countryOrRegion": "", 38 | "postalCode": "" 39 | }, 40 | "coordinates": { 41 | "latitude": 0, 42 | "longitude": 0 43 | } 44 | }, 45 | "attendees": [] 46 | } 47 | ] 48 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Validate with hassfest](https://github.com/RogerSelwyn/ms365-calendar/actions/workflows/hassfest.yaml/badge.svg)](https://github.com/RogerSelwyn/ms365-calendar/actions/workflows/hassfest.yaml) [![HACS Validate](https://github.com/RogerSelwyn/ms365-calendar/actions/workflows/hacs.yaml/badge.svg)](https://github.com/RogerSelwyn/ms365-calendar/actions/workflows/hacs.yaml) [![Python tests](https://github.com/RogerSelwyn/MS365-Calendar/actions/workflows/test.yaml/badge.svg)](https://github.com/RogerSelwyn/MS365-Calendar/actions/workflows/test.yaml) 2 | 3 | [![CodeFactor](https://www.codefactor.io/repository/github/rogerselwyn/ms365-calendar/badge)](https://www.codefactor.io/repository/github/rogerselwyn/ms365-calendar) [![Downloads for latest release](https://img.shields.io/github/downloads/RogerSelwyn/ms365-calendar/latest/total.svg)](https://github.com/RogerSelwyn/ms365-calendar/releases/latest) ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/RogerSelwyn/MS365-Calendar/total?label=downloads%40all) 4 | 5 | 6 | ![GitHub release](https://img.shields.io/github/v/release/RogerSelwyn/ms365-calendar) [![maintained](https://img.shields.io/maintenance/yes/2025.svg)](#) [![maintainer](https://img.shields.io/badge/maintainer-%20%40RogerSelwyn-blue.svg)](https://github.com/RogerSelwyn) [![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg)](https://github.com/hacs/integration) [![Community Forum](https://img.shields.io/badge/community-forum-brightgreen.svg)](https://community.home-assistant.io/t/office-365-calendar-access) 7 | 8 | # Microsoft 365 Calendar Integration for Home Assistant 9 | 10 | This integration enables: 11 | 1. Getting, creating, updating and responding to calendar events 12 | 13 | This project would not be possible without the wonderful [python-o365 project](https://github.com/O365/python-o365). 14 | 15 | ## [Buy Me A Beer 🍻](https://buymeacoffee.com/rogtp) 16 | I work on this integration because I like things to work well for myself and others. Please don't feel you are obligated to donate, but of course it is appreciated. 17 | 18 | Buy Me A Coffee 19 | 20 | Donate with PayPal 21 | 22 | 23 | # Documentation 24 | 25 | The full documentation is available here - [MS365 Documentation](https://rogerselwyn.github.io/MS365-HomeAssistant/) 26 | 27 | Also @fixtse has produced a video on installing the integrations, which can be seen on [YouTube](https://youtu.be/_g5I2y-xzaM?si=snmBIGNtM45-4EoW) 28 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/integration/store_integration.py: -------------------------------------------------------------------------------- 1 | """MS365 Calendar local storage.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | import logging 7 | from datetime import datetime 8 | from typing import Any 9 | 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.helpers.storage import Store 12 | 13 | from .const_integration import DOMAIN 14 | from .sync.store import CalendarStore 15 | 16 | STORAGE_KEY_FORMAT = "{domain}.Storage-{entry_id}" 17 | STORAGE_VERSION = 1 18 | # Buffer writes every few minutes (plus guaranteed to be written at shutdown) 19 | STORAGE_SAVE_DELAY_SECONDS = 120 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | class JSONEncoder(json.JSONEncoder): 25 | """Encoder for serialising an event""" 26 | 27 | def default(self, o): 28 | attributes = {} 29 | 30 | if not hasattr(o, "__dict__"): 31 | return 32 | for k, v in vars(o).items(): 33 | key = _beautify_key(k) 34 | if key not in [ 35 | "con", 36 | "protocol", 37 | "main_resource", 38 | "untrack", 39 | ] and not key.startswith("_"): 40 | if isinstance(v, datetime): 41 | val = str(v) 42 | elif hasattr(v, "value"): 43 | val = v.value 44 | else: 45 | val = v 46 | attributes[key] = val 47 | 48 | return attributes 49 | 50 | 51 | def _beautify_key(key): 52 | index = key.find("__") 53 | return key if index <= 0 else key[index + 2 :] 54 | 55 | 56 | class LocalCalendarStore(CalendarStore): 57 | """Storage for local persistence of calendar and event data.""" 58 | 59 | def __init__(self, hass: HomeAssistant, entry_id: str) -> None: 60 | """Initialize LocalCalendarStore.""" 61 | self._store = Store[dict[str, Any]]( 62 | hass, 63 | STORAGE_VERSION, 64 | STORAGE_KEY_FORMAT.format(domain=DOMAIN, entry_id=entry_id), 65 | private=True, 66 | encoder=JSONEncoder, 67 | ) 68 | self._data: dict[str, Any] | None = None 69 | 70 | async def async_load(self) -> dict[str, Any] | None: 71 | """Load data.""" 72 | if self._data is None: 73 | _LOGGER.debug("Load from store") 74 | self._data = await self._store.async_load() or {} 75 | return self._data 76 | 77 | async def async_save(self, data: dict[str, Any]) -> None: 78 | """Save data.""" 79 | self._data = data 80 | 81 | def provide_data() -> dict: 82 | _LOGGER.debug("Delayed save data") 83 | 84 | return data 85 | 86 | self._store.async_delay_save(provide_data, STORAGE_SAVE_DELAY_SECONDS) 87 | 88 | async def async_remove(self) -> None: 89 | """Remove data.""" 90 | await self._store.async_remove() 91 | -------------------------------------------------------------------------------- /docs/installation_and_configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installation and Configuration 3 | nav_order: 4 4 | --- 5 | 6 | # Installation and Configuration 7 | This page details the configuration details for this integration. General instructions can be found on the MS365 Home Assistant [Installation and Configuration](https://rogerselwyn.github.io/MS365-HomeAssistant/installation_and_configuration.html) page. 8 | 9 | ### Configuration variables 10 | 11 | Key | Type | Required | Description 12 | -- | -- | -- | -- 13 | `entity_name` | `string` | `True` | Uniquely identifying name for the account. Calendars entity names will be suffixed with this. `calendar.calendar_account1`. Do not use email address or spaces. 14 | `client_id` | `string` | `True` | Client ID from your Entra ID App Registration. 15 | `client_secret` | `string` | `True` | Client Secret from your Entra ID App Registration. 16 | `alt_auth_method` | `boolean` | `False` | If False (default), authentication is not dependent on internet access to your HA instance. [See Authentication](./authentication.md) 17 | `enable_update` | `boolean` | `False` | If True (**default is False**), this will enable the various services that allow updates to calendars 18 | `basic_calendar` | `boolean` | `False` | If True (**default is False**), the permission requested will be `calendar.ReadBasic`. `enable_update: true` = true cannot be used if `basic_calendar: true` 19 | `groups` | `boolean` | `False` | If True (**default is False**), will enable support for group calendars. No discovery is performed. You will need to know how to get the group ID from the MS Graph API. *Not for use on shared mailboxes* 20 | `shared_mailbox` | `string` | `False` | Email address or ID of shared mailbox (This should not be the same email address as the loggin in user). 21 | 22 | #### Advanced API Options 23 | 24 | These options will only be relevant for users in very specific circumstances. 25 | 26 | Key | Type | Required | Description 27 | -- | -- | -- | -- 28 | `country` | `string` | `True` | Selection of an alternate country specific API. Currently only 21Vianet from China. 29 | 30 | ### Options 31 | 32 | Key | Type | Required | Description 33 | -- | -- | -- | -- 34 | `calendar_list` | `list[string]` | `False` | The selectable list of calendars for which calendar entities will be created. 35 | `track_new_calendar` | `boolean` | `False` | If True (default), will automatically generate a calendar_entity when a new calendar is detected. The system scans for new calendars only on startup or reconfiguration/reload. 36 | 37 | ### Advanced Options 38 | 39 | Key | Type | Required | Description 40 | -- | -- | -- | -- 41 | `update_interval` | `integer` | `False` | How often in seconds that events will be retrieved and synced to store. Default 60. Range: 15 - 600 42 | `days_backward` | `integer` | `False` | The days backward from `now` for which events will be synced to store. Default -8. Range: -90 - 90 43 | `days_forward` | `integer` | `False` | The days forward from `now` for which events will be synced to store. Default 8. Range: -90 - 90 44 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/integration/const_integration.py: -------------------------------------------------------------------------------- 1 | """Calendar constants.""" 2 | 3 | from enum import Enum 4 | 5 | from homeassistant.const import Platform 6 | 7 | 8 | class EventResponse(Enum): 9 | """Event response.""" 10 | 11 | Accept = "accept" # pylint: disable=invalid-name 12 | Tentative = "tentative" # pylint: disable=invalid-name 13 | Decline = "decline" # pylint: disable=invalid-name 14 | 15 | 16 | PLATFORMS: list[Platform] = [Platform.CALENDAR] 17 | DOMAIN = "ms365_calendar" 18 | 19 | ATTR_ALL_DAY = "all_day" 20 | ATTR_ATTENDEES = "attendees" 21 | ATTR_BODY = "body" 22 | ATTR_CATEGORIES = "categories" 23 | ATTR_COLOR = "color" 24 | ATTR_DATA = "data" 25 | ATTR_EMAIL = "email" 26 | ATTR_END = "end" 27 | ATTR_EVENT_ID = "event_id" 28 | ATTR_HEX_COLOR = "hex_color" 29 | ATTR_IS_ALL_DAY = "is_all_day" 30 | ATTR_LOCATION = "location" 31 | ATTR_MESSAGE = "message" 32 | ATTR_OFFSET = "offset_reached" 33 | ATTR_RESPONSE = "response" 34 | ATTR_RRULE = "rrule" 35 | ATTR_SEND_RESPONSE = "send_response" 36 | ATTR_SENSITIVITY = "sensitivity" 37 | ATTR_SHOW_AS = "show_as" 38 | ATTR_START = "start" 39 | ATTR_SUBJECT = "subject" 40 | ATTR_SYNC_STATE = "sync_state" 41 | ATTR_TYPE = "type" 42 | 43 | CALENDAR_ENTITY_ID_FORMAT = "calendar.{}" 44 | 45 | CONF_ADVANCED_OPTIONS = "advanced_options" 46 | CONF_BASIC_CALENDAR = "basic_calendar" 47 | CONF_CAL_ID = "cal_id" 48 | CONF_CALENDAR_LIST = "calendar_list" 49 | CONF_CAN_EDIT = "can_edit" 50 | CONF_DAYS_BACKWARD = "days_backward" 51 | CONF_DAYS_FORWARD = "days_forward" 52 | CONF_DEVICE_ID = "device_id" 53 | CONF_ENTITIES = "entities" 54 | CONF_ENTITY = "entity" 55 | CONF_EXCLUDE = "exclude" 56 | CONF_GROUPS = "groups" 57 | CONF_HOURS_BACKWARD_TO_GET = "start_offset" 58 | CONF_HOURS_FORWARD_TO_GET = "end_offset" 59 | CONF_MAX_RESULTS = "max_results" 60 | CONF_SEARCH = "search" 61 | CONF_SENSITIVITY_EXCLUDE = "sensitivity_exclude" 62 | CONF_TRACK = "track" 63 | CONF_TRACK_NEW_CALENDAR = "track_new_calendar" 64 | CONF_UPDATE_INTERVAL = "update_interval" 65 | 66 | 67 | CONST_GROUP = "group:" 68 | 69 | DAYS = { 70 | "MO": "monday", 71 | "TU": "tuesday", 72 | "WE": "wednesday", 73 | "TH": "thursday", 74 | "FR": "friday", 75 | "SA": "saturday", 76 | "SU": "sunday", 77 | } 78 | 79 | DEFAULT_DAYS_BACKWARD = -8 80 | DEFAULT_DAYS_FORWARD = 8 81 | DEFAULT_OFFSET = "!!" 82 | DEFAULT_UPDATE_INTERVAL = 60 83 | 84 | EVENT_CREATE_CALENDAR_EVENT = "create_calendar_event" 85 | EVENT_MODIFY_CALENDAR_EVENT = "modify_calendar_event" 86 | EVENT_MODIFY_CALENDAR_RECURRENCES = "modify_calendar_recurrences" 87 | EVENT_REMOVE_CALENDAR_EVENT = "remove_calendar_event" 88 | EVENT_REMOVE_CALENDAR_RECURRENCES = "remove_calendar_recurrences" 89 | EVENT_RESPOND_CALENDAR_EVENT = "respond_calendar_event" 90 | EVENT_SYNC = "event_sync" 91 | 92 | INDEXES = { 93 | "+1": "first", 94 | "+2": "second", 95 | "+3": "third", 96 | "+4": "fourth", 97 | "-1": "last", 98 | } 99 | ITEMS = "items" 100 | 101 | PERM_CALENDARS_READ = "Calendars.Read" 102 | PERM_CALENDARS_READBASIC = "Calendars.ReadBasic" 103 | PERM_CALENDARS_READWRITE = "Calendars.ReadWrite" 104 | PERM_GROUP_READ_ALL = "Group.Read.All" 105 | PERM_GROUP_READWRITE_ALL = "Group.ReadWrite.All" 106 | 107 | YAML_CALENDARS_FILENAME = "ms365_calendars{0}.yaml" 108 | -------------------------------------------------------------------------------- /tests/integration/const_integration.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=unused-import 2 | """Constants for calendar integration.""" 3 | 4 | from copy import deepcopy 5 | from enum import Enum 6 | 7 | from custom_components.ms365_calendar.config_flow import MS365ConfigFlow # noqa: F401 8 | from custom_components.ms365_calendar.const import ( # noqa: F401 9 | AUTH_CALLBACK_PATH_ALT, 10 | COUNTRY_URLS, 11 | OAUTH_REDIRECT_URL, 12 | CountryOptions, 13 | ) 14 | from custom_components.ms365_calendar.integration.const_integration import ( 15 | DOMAIN, # noqa: F401 16 | ) 17 | 18 | from ..const import CLIENT_ID, CLIENT_SECRET, ENTITY_NAME 19 | 20 | AUTH_CALLBACK_PATH_DEFAULT = COUNTRY_URLS[CountryOptions.DEFAULT][OAUTH_REDIRECT_URL] 21 | BASE_CONFIG_ENTRY = { 22 | "entity_name": ENTITY_NAME, 23 | "client_id": CLIENT_ID, 24 | "client_secret": CLIENT_SECRET, 25 | "alt_auth_method": False, 26 | "enable_update": False, 27 | "basic_calendar": False, 28 | "groups": False, 29 | "shared_mailbox": "", 30 | "api_options": {"country": "Default"}, 31 | } 32 | BASE_TOKEN_PERMS = "Calendars.Read" 33 | BASE_MISSING_PERMS = BASE_TOKEN_PERMS 34 | SHARED_TOKEN_PERMS = "Calendars.Read.Shared" 35 | UPDATE_TOKEN_PERMS = "Calendars.ReadWrite" 36 | UPDATE_OPTIONS = {"enable_update": True} 37 | 38 | ALT_CONFIG_ENTRY = deepcopy(BASE_CONFIG_ENTRY) 39 | ALT_CONFIG_ENTRY["alt_auth_method"] = True 40 | COUNTRY_CONFIG_ENTRY = deepcopy(BASE_CONFIG_ENTRY) 41 | COUNTRY_CONFIG_ENTRY["api_options"]["country"] = "21Vianet (China)" 42 | 43 | RECONFIGURE_CONFIG_ENTRY = deepcopy(BASE_CONFIG_ENTRY) 44 | del RECONFIGURE_CONFIG_ENTRY["entity_name"] 45 | 46 | MIGRATION_CONFIG_ENTRY = { 47 | "data": BASE_CONFIG_ENTRY, 48 | "options": {}, 49 | "calendars": { 50 | "calendar1": { 51 | "cal_id": "calendar1", 52 | "entities": [ 53 | { 54 | "device_id": "Calendar", 55 | "end_offset": 6, 56 | "name": "Calendar", 57 | "start_offset": 0, 58 | "track": False, 59 | } 60 | ], 61 | }, 62 | }, 63 | } 64 | 65 | 66 | DIAGNOSTIC_GRANTED_PERMISSIONS = [ 67 | "Calendars.Read", 68 | "User.Read", 69 | "email", 70 | "openid", 71 | "profile", 72 | ] 73 | DIAGNOSTIC_REQUESTED_PERMISSIONS = [ 74 | "User.Read", 75 | "Calendars.Read", 76 | ] 77 | 78 | FULL_INIT_ENTITY_NO = 3 79 | 80 | UPDATE_CALENDAR_LIST = ["Calendar1"] 81 | 82 | 83 | class URL(Enum): 84 | """List of URLs""" 85 | 86 | OPENID = ( 87 | "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration" 88 | ) 89 | ME = "https://graph.microsoft.com/v1.0/me" 90 | CALENDARS = "https://graph.microsoft.com/v1.0/me/calendars" 91 | GROUP_CALENDARS = "https://graph.microsoft.com/v1.0/groups" 92 | SHARED_CALENDARS = ( 93 | "https://graph.microsoft.com/v1.0/users/jane.doe@nomail.com/calendars" 94 | ) 95 | 96 | 97 | class CN21VURL(Enum): 98 | """List of URLs""" 99 | 100 | DISCOVERY = "https://login.microsoftonline.com/common/discovery/instance" 101 | OPENID = "https://login.partner.microsoftonline.cn/common/v2.0/.well-known/openid-configuration" 102 | ME = "https://microsoftgraph.chinacloudapi.cn/v1.0/me" 103 | CALENDARS = "https://microsoftgraph.chinacloudapi.cn/v1.0/me/calendars" 104 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help us improve 3 | title: "[BUG]" 4 | labels: Bug 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Please complete this form as fully and as accurately as possible. The more complete it is the quicker the problem will likely be solved. 10 | 11 | - type: textarea 12 | validations: 13 | required: true 14 | attributes: 15 | label: Describe the bug 16 | description: >- 17 | Describe the issue you are experiencing here, to communicate to the 18 | maintainers. Tell us what you were trying to do and what happened. 19 | 20 | Provide a clear and concise description of what the problem is. 21 | - type: textarea 22 | validations: 23 | required: true 24 | attributes: 25 | label: To reproduce 26 | description: >- 27 | Steps to reproduce the behaviour. 28 | - type: textarea 29 | validations: 30 | required: true 31 | attributes: 32 | label: Expected behaviour 33 | description: >- 34 | A clear and concise description of what you expected to happen. 35 | - type: markdown 36 | attributes: 37 | value: | 38 | ## Environment 39 | - type: input 40 | id: ms365version 41 | validations: 42 | required: true 43 | attributes: 44 | label: What version of the MS365 Calendar has the issue? 45 | placeholder: version 46 | description: > 47 | Can be found in: HACS ⇒ Integrations ⇒ MS365 Calendar 48 | - type: input 49 | attributes: 50 | label: What was the last working version of MS365 Calendar Integration? 51 | placeholder: version 52 | description: > 53 | If known, otherwise leave blank. 54 | - type: input 55 | id: haversion 56 | validations: 57 | required: true 58 | attributes: 59 | label: What version of Home Assistant Core has the issue? 60 | placeholder: core- 61 | description: > 62 | Can be found in: [Settings ⇒ System ⇒ Repairs ⇒ Three Dots in Upper Right ⇒ System information](https://my.home-assistant.io/redirect/system_health/). 63 | 64 | [![Open your Home Assistant instance and show the system information.](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/) 65 | 66 | - type: textarea 67 | attributes: 68 | label: Diagnostics information 69 | placeholder: "drag-and-drop the diagnostics data file here (do not copy-and-paste the content)" 70 | description: >- 71 | Many integrations provide the ability to [download diagnostic data](https://www.home-assistant.io/docs/configuration/troubleshooting/#debug-logs-and-diagnostics). 72 | 73 | **It would really help if you could download the diagnostics data for the integration instance you are having issues with, 74 | and drag-and-drop that file into the textbox below.** 75 | 76 | It generally allows pinpointing defects and thus resolving issues faster. 77 | 78 | - type: textarea 79 | attributes: 80 | label: Anything in the logs that might be useful for us? 81 | description: Look in the `Full log`, click three dots at top of system log and select `Show full logs`. For example, error message, or stack traces. Also look for O365 errors. 82 | render: txt 83 | 84 | - type: textarea 85 | attributes: 86 | label: Additional information 87 | description: > 88 | If you have any additional information for us, use the field below. 89 | -------------------------------------------------------------------------------- /docs/calendar_configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Calendar Configuration 3 | nav_order: 6 4 | --- 5 | 6 | # Calendar configuration 7 | The integration uses an external `ms365_calendars_.yaml` file which is stored in the `ms365_storage` directory. Much of this can be managed by the UI, but more complex items must be managed via the yaml file. Items that can by the standard integration configure UI are: 8 | * name 9 | * track 10 | * end_offset 11 | * start_offset 12 | * max_results 13 | 14 | Group calendars plus device_id, search and exclude must be managed via the yaml file. 15 | 16 | ## Example Calendar yaml: 17 | ```yaml 18 | - cal_id: xxxx 19 | entities: 20 | - device_id: work_calendar 21 | end_offset: 24 22 | name: My Work Calendar 23 | start_offset: 0 24 | track: true 25 | 26 | - cal_id: xxxx 27 | entities: 28 | - device_id: birthdays 29 | end_offset: 24 30 | name: Birthdays 31 | start_offset: 0 32 | track: true 33 | ``` 34 | 35 | ### Calendar yaml configuration variables 36 | 37 | Key | Type | Required | Description 38 | -- | -- | -- | -- 39 | `cal_id` | `string` | `True` | Microsoft 365 generated unique ID, DO NOT CHANGE 40 | `entities` | `list` | `True` | List of entities (see below) to generate from this calendar 41 | 42 | ### Entity configuration 43 | 44 | Key | Type | Required | Description 45 | -- | -- | -- | -- 46 | `device_id` | `string` | `True` | The entity_id will be "calendar.{device_id}" 47 | `name` | `string` | `True` | The name of your sensor that you’ll see in the frontend. 48 | `track` | `boolean` | `True` | **True**=Create calendar entity. False=Don't create entity 49 | `search` | `string` | `False` | Only get events if subject contains this string 50 | `exclude` | `list[string/regex]` | `False` | Exclude events where the subject contains any one of items in the list of strings 51 | `start_offset` | `integer` | `False` | Number of hours to offset the start time to search for events for (negative numbers to offset into the past). 52 | `end_offset` | `integer` | `False` | Number of hours to offset the end time to search for events for (negative numbers to offset into the past). 53 | `max_results` | `integer` | `False` | Max number of events to retrieve. Default is 999. 54 | `sensitivity_exclude` | `list[string]` | `False` | List of sensitivities to exclude from the calendar (`normal`/`personal`/`private`/`confidential`) 55 | 56 | ## Group calendars 57 | 58 | The integration supports Group calendars in a fairly simple form. The below are the constraints. 59 | * This gets the default calendar for the group. 60 | * There is no discovery. You will need to find them in the MS Graph api. Using the MS Graph API you can call https://graph.microsoft.com/v1.0/me/transitiveMemberOf/microsoft.graph.group to get the groups. You will need the relevant group's `id` for configuration purposes, see below 61 | * You can create events using the standard service, but you cannot modify/delete/respond to them. 62 | 63 | To configure a Group Calendar, add an extra section to `ms365_calendars_.yaml`. Set `cal_id` to `group:xxxxxxxxxxxxxxx` using the ID you found via the api above. Make sure to set the `device_id` to something unique. 64 | 65 | ```yaml 66 | - cal_id: group:xxxx 67 | entities: 68 | - device_id: group_calendar 69 | end_offset: 24 70 | name: Group Calendar 71 | start_offset: 0 72 | track: true 73 | ``` 74 | 75 | ## Exclude 76 | 77 | To exclude calendar items from being displayed, e.g. cancelled events, the exclude attribute can be used. This takes straight strings or can be configured with a regex for more complex exclusions. 78 | 79 | ```yaml 80 | exclude: 81 | - "Cancelled" 82 | - "^In.*Junk$" 83 | ``` 84 | 85 | ## Sensitivity Exclude 86 | 87 | To exclude specific sensitivities from being included in the calendar. 88 | 89 | ```yaml 90 | sensitivity_exclude: 91 | - private 92 | - confidential 93 | ``` 94 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/const.py: -------------------------------------------------------------------------------- 1 | """Constants.""" 2 | 3 | from enum import StrEnum 4 | 5 | ATTR_DATA = "data" 6 | ATTR_ERROR = "error" 7 | ATTR_STATE = "state" 8 | 9 | AUTH_CALLBACK_NAME = "api:ms365" 10 | AUTH_CALLBACK_PATH_ALT = "/api/ms365" 11 | 12 | CONF_ENTITY_NAME = "entity_name" 13 | CONF_ALT_AUTH_METHOD = "alt_auth_method" 14 | CONF_API_COUNTRY = "country" 15 | CONF_API_OPTIONS = "api_options" 16 | CONF_AUTH_URL = "auth_url" 17 | CONF_CLIENT_ID = "client_id" 18 | CONF_CLIENT_SECRET = "client_secret" # nosec 19 | CONF_ENABLE_UPDATE = "enable_update" 20 | CONF_ENTITY_KEY = "entity_key" 21 | CONF_ENTITY_TYPE = "entity_type" 22 | CONF_FAILED_PERMISSIONS = "failed_permissions" 23 | CONF_SHARED_MAILBOX = "shared_mailbox" 24 | CONF_URL = "url" 25 | 26 | CONST_UTC_TIMEZONE = "UTC" 27 | 28 | DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z" 29 | 30 | EVENT_HA_EVENT = "ha_event" 31 | 32 | MS365_STORAGE = "ms365_storage" 33 | MS365_STORAGE_TOKEN = ".MS365-token-cache" 34 | 35 | PERM_USER_READ = "User.Read" 36 | PERM_SHARED = ".Shared" 37 | PERM_BASE_PERMISSIONS = [PERM_USER_READ] 38 | 39 | ERROR_IMPORTED_DUPLICATE = "Entry already imported for '%s' - '%s'" 40 | ERROR_INVALID_SHARED_MAILBOX = ( 41 | "Login email address '%s' should not be " 42 | + "entered as shared email address, config attribute removed." 43 | ) 44 | SECRET_EXPIRED = ( 45 | "Client Secret expired for account: %s. " 46 | + "Create new Client Secret in Entra ID App Registration." 47 | ) 48 | TOKEN_DELETED = ( 49 | "Token %s has been deleted as part of upgrade" 50 | + " - please re-configure to re-authenticate" 51 | ) 52 | TOKEN_ERROR = "Token error for account: %s. Error - %s" 53 | TOKEN_ERROR_CORRUPT = ( 54 | "Token file corrupted for integration '%s', unique identifier '%s', " 55 | + "please delete token, re-configure and re-authenticate - %s" 56 | ) 57 | TOKEN_ERROR_FILE = ( 58 | "Token file retrieval error, check log for errors from O365. " 59 | + "Ensure token has not expired and you are using secret value not secret id." 60 | ) 61 | TOKEN_ERROR_LEGACY = ( 62 | "Token no longer valid for integration '%s', unique identifier '%s', " 63 | + "please delete token, re-configure and re-authenticate - %s" 64 | ) 65 | TOKEN_ERROR_MISSING = "Could not locate token at %s" 66 | TOKEN_ERROR_PERMISSIONS = ( 67 | "Minimum required permissions: '%s'. Not available in token '%s' for account '%s'." 68 | ) 69 | TOKEN_EXPIRED = ( 70 | "Token has expired for account: '%s'. " + "Please re-configure and re-authenticate." 71 | ) 72 | 73 | 74 | TOKEN_FILENAME = "{0}{1}.token" # nosec 75 | TOKEN_FILE_CORRUPTED = "corrupted" 76 | TOKEN_FILE_EXPIRED = "expired" 77 | TOKEN_FILE_MISSING = "missing" 78 | TOKEN_FILE_OUTDATED = "outdated" 79 | TOKEN_FILE_PERMISSIONS = "permissions" 80 | TOKEN_INVALID = "The token you are trying to load is not valid anymore" 81 | 82 | 83 | class CountryOptions(StrEnum): 84 | """Teams sensors enablement.""" 85 | 86 | DEFAULT = "Default" 87 | CN21V = "21Vianet (China)" 88 | 89 | 90 | MSAL_AUTHORITY = "msal_authority" 91 | OAUTH_REDIRECT_URL = "auth_redirect_url" 92 | OAUTH_SCOPE_PREFIX = "oauth_scope_prefix" 93 | PERMISSION_PREFIX = "permission_prefix" 94 | PROTOCOL_URL = "protocol_url" 95 | 96 | COUNTRY_URLS = { 97 | CountryOptions.CN21V: { 98 | MSAL_AUTHORITY: "https://login.partner.microsoftonline.cn/common", 99 | OAUTH_REDIRECT_URL: "https://login.partner.microsoftonline.cn/common/oauth2/nativeclient", 100 | OAUTH_SCOPE_PREFIX: "https://microsoftgraph.chinacloudapi.cn/", 101 | PERMISSION_PREFIX: "https://microsoftgraph.chinacloudapi.cn/", 102 | PROTOCOL_URL: "https://microsoftgraph.chinacloudapi.cn/", 103 | }, 104 | CountryOptions.DEFAULT: { 105 | OAUTH_REDIRECT_URL: "https://login.microsoftonline.com/common/oauth2/nativeclient", 106 | PERMISSION_PREFIX: "https://graph.microsoft.com/", 107 | }, 108 | } 109 | -------------------------------------------------------------------------------- /tests/integration/fixtures.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=redefined-outer-name, unused-argument 2 | """Fixtures specific to the integration.""" 3 | 4 | from collections.abc import Awaitable, Callable 5 | from dataclasses import dataclass 6 | from typing import Any 7 | from unittest.mock import Mock, patch 8 | 9 | import pytest 10 | from aiohttp import ClientWebSocketResponse 11 | from aiohttp.test_utils import TestClient 12 | from homeassistant.core import Event, HomeAssistant 13 | from pytest_homeassistant_custom_component.typing import ( 14 | ClientSessionGenerator, 15 | WebSocketGenerator, 16 | ) 17 | 18 | from custom_components.ms365_calendar.integration import filemgmt_integration 19 | 20 | from ..const import STORAGE_LOCATION 21 | from .const_integration import DOMAIN 22 | 23 | 24 | @pytest.fixture(autouse=True) 25 | def yaml_storage_path_setup(tmp_path): 26 | """Setup the storage paths.""" 27 | yml_path = tmp_path / STORAGE_LOCATION / f"{DOMAIN}s_test.yaml" 28 | 29 | with patch.object( 30 | filemgmt_integration, 31 | "build_config_file_path", 32 | return_value=yml_path, 33 | ): 34 | yield 35 | 36 | 37 | @dataclass 38 | class ListenerSetupData: 39 | """A collection of data set up by the listener_setup fixture.""" 40 | 41 | hass: HomeAssistant 42 | client: TestClient 43 | event_listener: Mock 44 | events: any 45 | 46 | 47 | @pytest.fixture 48 | async def listener_setup( 49 | hass: HomeAssistant, 50 | hass_client_no_auth: ClientSessionGenerator, 51 | ) -> ListenerSetupData: 52 | """Set up integration, client and webhook url.""" 53 | 54 | client = await hass_client_no_auth() 55 | 56 | events = [] 57 | 58 | async def event_listener(event: Event) -> None: 59 | events.append(event) 60 | 61 | hass.bus.async_listen(f"{DOMAIN}_create_calendar_event", event_listener) 62 | hass.bus.async_listen(f"{DOMAIN}_modify_calendar_event", event_listener) 63 | hass.bus.async_listen(f"{DOMAIN}_remove_calendar_event", event_listener) 64 | hass.bus.async_listen(f"{DOMAIN}_remove_calendar_recurrences", event_listener) 65 | hass.bus.async_listen(f"{DOMAIN}_respond_calendar_event", event_listener) 66 | 67 | return ListenerSetupData(hass, client, event_listener, events) 68 | 69 | 70 | class Client: 71 | """Test client with helper methods for calendar websocket.""" 72 | 73 | def __init__(self, client: ClientWebSocketResponse) -> None: 74 | """Initialize Client.""" 75 | self.client = client 76 | self.id = 0 77 | 78 | async def cmd( 79 | self, cmd: str, payload: dict[str, Any] | None = None 80 | ) -> dict[str, Any]: 81 | """Send a command and receive the json result.""" 82 | self.id += 1 83 | await self.client.send_json( 84 | { 85 | "id": self.id, 86 | "type": f"calendar/event/{cmd}", 87 | **(payload if payload is not None else {}), 88 | } 89 | ) 90 | resp = await self.client.receive_json() 91 | assert resp.get("id") == self.id 92 | return resp 93 | 94 | async def cmd_result( 95 | self, cmd: str, payload: dict[str, Any] | None = None 96 | ) -> dict[str, Any] | None: 97 | """Send a command and parse the result.""" 98 | resp = await self.cmd(cmd, payload) 99 | assert resp.get("success") 100 | assert resp.get("type") == "result" 101 | return resp.get("result") 102 | 103 | 104 | type ClientFixture = Callable[[], Awaitable[Client]] 105 | 106 | 107 | @pytest.fixture 108 | async def ws_client( 109 | hass: HomeAssistant, 110 | hass_ws_client: WebSocketGenerator, 111 | ) -> ClientFixture: 112 | """Fixture for creating the test websocket client.""" 113 | 114 | async def create_client() -> Client: 115 | ws_client = await hass_ws_client(hass) 116 | return Client(ws_client) 117 | 118 | return create_client 119 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | 163 | # Added 164 | .DS_Store 165 | .vscode/ltex.dictionary.en-GB.txt 166 | .vscode/ltex.disabledRules.en-GB.txt 167 | tests/whitelist.py 168 | 169 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/integration/filemgmt_integration.py: -------------------------------------------------------------------------------- 1 | """Calendar file management processes.""" 2 | 3 | import logging 4 | import os 5 | 6 | import yaml 7 | from homeassistant.const import CONF_NAME 8 | from voluptuous.error import Error as VoluptuousError 9 | 10 | from ..classes.config_entry import MS365ConfigEntry 11 | from ..const import ( 12 | CONF_ENTITY_NAME, 13 | ) 14 | from ..helpers.filemgmt import build_config_file_path 15 | from .const_integration import ( 16 | CONF_CAL_ID, 17 | CONF_DEVICE_ID, 18 | CONF_ENTITIES, 19 | CONF_TRACK, 20 | YAML_CALENDARS_FILENAME, 21 | ) 22 | from .schema_integration import YAML_CALENDAR_DEVICE_SCHEMA 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | 27 | def load_yaml_file(path, item_id, item_schema): 28 | """Load the ms365 yaml file.""" 29 | items = {} 30 | try: 31 | with open(path, encoding="utf8") as file: 32 | data = yaml.safe_load(file) 33 | if data is None: 34 | return {} 35 | for item in data: 36 | try: 37 | items[item[item_id]] = item_schema(item) 38 | except VoluptuousError as exception: 39 | # keep going 40 | _LOGGER.warning( 41 | "Invalid Data - duplicate entries may be created in file %s: %s", 42 | path, 43 | exception, 44 | ) 45 | except FileNotFoundError: 46 | # When YAML file could not be loaded/did not contain a dict 47 | return {} 48 | 49 | return items 50 | 51 | 52 | def write_yaml_file(yaml_filepath, cal): 53 | """Write to the calendar file.""" 54 | dirpath = os.path.dirname(yaml_filepath) 55 | if not os.path.isdir(dirpath): 56 | os.makedirs(dirpath) 57 | with open(yaml_filepath, "a", encoding="UTF8") as out: 58 | out.write("\n") 59 | yaml.dump([cal], out, default_flow_style=False, encoding="UTF8") 60 | out.close() 61 | 62 | 63 | def _get_calendar_info(calendar, track_new_devices): 64 | """Convert data from MS365 into DEVICE_SCHEMA.""" 65 | return YAML_CALENDAR_DEVICE_SCHEMA( 66 | { 67 | CONF_CAL_ID: calendar.calendar_id, 68 | CONF_ENTITIES: [ 69 | { 70 | CONF_TRACK: track_new_devices, 71 | CONF_NAME: calendar.name, 72 | CONF_DEVICE_ID: calendar.name, 73 | } 74 | ], 75 | } 76 | ) 77 | 78 | 79 | async def async_update_calendar_file( 80 | entry: MS365ConfigEntry, calendar, hass, track_new_devices 81 | ): 82 | """Update the calendar file.""" 83 | path = build_yaml_filename(entry, YAML_CALENDARS_FILENAME) 84 | yaml_filepath = build_yaml_file_path(hass, path) 85 | existing_calendars = await hass.async_add_executor_job( 86 | load_yaml_file, yaml_filepath, CONF_CAL_ID, YAML_CALENDAR_DEVICE_SCHEMA 87 | ) 88 | cal = _get_calendar_info(calendar, track_new_devices) 89 | if cal[CONF_CAL_ID] in existing_calendars: 90 | return 91 | await hass.async_add_executor_job(write_yaml_file, yaml_filepath, cal) 92 | 93 | 94 | def build_yaml_filename(conf: MS365ConfigEntry, filename): 95 | """Create the token file name.""" 96 | 97 | return filename.format(f"_{conf.data.get(CONF_ENTITY_NAME)}") 98 | 99 | 100 | def build_yaml_file_path(hass, yaml_filename): 101 | """Create yaml path.""" 102 | return build_config_file_path(hass, yaml_filename) 103 | 104 | 105 | def read_calendar_yaml_file(yaml_filepath): 106 | """Read the yaml file.""" 107 | with open(yaml_filepath, encoding="utf8") as file: 108 | return yaml.safe_load(file) 109 | 110 | 111 | def write_calendar_yaml_file(yaml_filepath, contents): 112 | """Write the yaml file.""" 113 | with open(yaml_filepath, "w", encoding="UTF8") as out: 114 | yaml.dump(contents, out, default_flow_style=False, encoding="UTF8") 115 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/integration/sync/sync.py: -------------------------------------------------------------------------------- 1 | """Library for handling local event sync.""" 2 | 3 | import logging 4 | import re 5 | 6 | from requests.exceptions import ConnectionError as RequestConnectionError 7 | from requests.exceptions import HTTPError, RetryError 8 | 9 | from ..const_integration import EVENT_SYNC, ITEMS 10 | from .api import MS365CalendarEventStoreService, MS365CalendarService 11 | from .store import CalendarStore, ScopedCalendarStore 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | class MS365CalendarEventSyncManager: 17 | """Manages synchronizing events from API to local store.""" 18 | 19 | def __init__( 20 | self, 21 | api: MS365CalendarService, 22 | calendar_id: str | None = None, 23 | store: CalendarStore | None = None, 24 | exclude: list | None = None, 25 | ) -> None: 26 | """Initialize CalendarEventSyncManager.""" 27 | self._api = api 28 | self.calendar_id = calendar_id 29 | self._store = ScopedCalendarStore( 30 | ScopedCalendarStore(store, EVENT_SYNC), self.calendar_id 31 | ) 32 | self._exclude = exclude 33 | 34 | @property 35 | def store_service(self) -> MS365CalendarEventStoreService: 36 | """Return the local API for fetching events.""" 37 | return MS365CalendarEventStoreService(self._store, self.calendar_id, self._api) 38 | 39 | @property 40 | def api(self) -> MS365CalendarService: 41 | """Return the cloud API.""" 42 | return self._api 43 | 44 | async def async_list_events(self, start_date, end_date): 45 | """Return the set of events matching the criteria.""" 46 | events = await self._api.async_list_events(start_date, end_date) 47 | 48 | return self._filter_events(list(events)) 49 | 50 | def _filter_events(self, events): 51 | if not events or not self._exclude: 52 | return events 53 | 54 | rtn_events = [] 55 | for event in events: 56 | include = True 57 | for exclude in self._exclude: 58 | if re.search(exclude, event.subject): 59 | include = False 60 | if include: 61 | rtn_events.append(event) 62 | 63 | return rtn_events 64 | 65 | async def run(self, start_date, end_date) -> None: 66 | """Run the event sync manager.""" 67 | # store_data = await self._store.async_load() or {} 68 | 69 | try: 70 | new_data = await self.async_list_events( 71 | start_date=start_date, 72 | end_date=end_date, 73 | ) 74 | 75 | except (HTTPError, RetryError, RequestConnectionError) as err: 76 | raise err 77 | 78 | # store_data[ITEMS].update(_add_update_func(store_data, new_data)) 79 | items = {item.object_id: item for item in new_data} 80 | store_data = {ITEMS: items} 81 | await self._store.async_save(store_data) 82 | 83 | 84 | # def _add_update_func(store_data, new_data) -> dict[str, Any]: 85 | # items = {} 86 | # for item in new_data: 87 | # items[item.object_id] = item 88 | # for key, value in store_data[ITEMS].items(): 89 | # if key not in items and isinstance(value, dict): 90 | # items[key] = DictObj(value) 91 | # return items 92 | 93 | 94 | # class DictObj: 95 | # """To convert from dict to object.""" 96 | 97 | # def __init__(self, in_dict: dict): 98 | # assert isinstance(in_dict, dict) 99 | # for key, val in in_dict.items(): 100 | # if isinstance(val, (list, tuple)): 101 | # setattr( 102 | # self, key, [DictObj(x) if isinstance(x, dict) else x for x in val] 103 | # ) 104 | # elif key in ["start", "end"]: 105 | # setattr(self, key, parser.parse(val)) 106 | # elif key in ["location"]: 107 | # setattr(self, key, val) 108 | # elif key in ["sensitivity"]: 109 | # setattr(self, key, EventSensitivity(val)) 110 | # elif key in ["show_as"]: 111 | # setattr(self, key, EventShowAs(val)) 112 | # elif key in ["importance"]: 113 | # setattr(self, key, ImportanceLevel(val)) 114 | # else: 115 | # setattr(self, key, DictObj(val) if isinstance(val, dict) else val) 116 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | branches: [ "main" ] 19 | schedule: 20 | - cron: '18 17 * * 3' 21 | 22 | jobs: 23 | analyze: 24 | name: Analyze (${{ matrix.language }}) 25 | # Runner size impacts CodeQL analysis time. To learn more, please see: 26 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 27 | # - https://gh.io/supported-runners-and-hardware-resources 28 | # - https://gh.io/using-larger-runners (GitHub.com only) 29 | # Consider using larger runners or machines with greater resources for possible analysis time improvements. 30 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 31 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 32 | permissions: 33 | # required for all workflows 34 | security-events: write 35 | 36 | # required to fetch internal or private CodeQL packs 37 | packages: read 38 | 39 | # only required for workflows in private repositories 40 | actions: read 41 | contents: read 42 | 43 | strategy: 44 | fail-fast: false 45 | matrix: 46 | include: 47 | - language: python 48 | build-mode: none 49 | # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' 50 | # Use `c-cpp` to analyze code written in C, C++ or both 51 | # Use 'java-kotlin' to analyze code written in Java, Kotlin or both 52 | # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 53 | # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, 54 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. 55 | # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how 56 | # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages 57 | steps: 58 | - name: Checkout repository 59 | uses: actions/checkout@v6 60 | 61 | # Initializes the CodeQL tools for scanning. 62 | - name: Initialize CodeQL 63 | uses: github/codeql-action/init@v4 64 | with: 65 | languages: ${{ matrix.language }} 66 | build-mode: ${{ matrix.build-mode }} 67 | # If you wish to specify custom queries, you can do so here or in a config file. 68 | # By default, queries listed here will override any specified in a config file. 69 | # Prefix the list here with "+" to use these queries and those in the config file. 70 | 71 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 72 | # queries: security-extended,security-and-quality 73 | 74 | # If the analyze step fails for one of the languages you are analyzing with 75 | # "We were unable to automatically build your code", modify the matrix above 76 | # to set the build mode to "manual" for that language. Then modify this step 77 | # to build your code. 78 | # ℹ️ Command-line programs to run using the OS shell. 79 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 80 | - if: matrix.build-mode == 'manual' 81 | shell: bash 82 | run: | 83 | echo 'If you are using a "manual" build mode for one or more of the' \ 84 | 'languages you are analyzing, replace this with the commands to build' \ 85 | 'your code, for example:' 86 | echo ' make bootstrap' 87 | echo ' make release' 88 | exit 1 89 | 90 | - name: Perform CodeQL Analysis 91 | uses: github/codeql-action/analyze@v4 92 | with: 93 | category: "/language:${{matrix.language}}" 94 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/classes/permissions.py: -------------------------------------------------------------------------------- 1 | """Generic Permissions processes.""" 2 | 3 | import logging 4 | from copy import deepcopy 5 | 6 | from ..const import ( 7 | CONF_ENTITY_NAME, 8 | COUNTRY_URLS, 9 | PERMISSION_PREFIX, 10 | TOKEN_ERROR_CORRUPT, 11 | TOKEN_ERROR_PERMISSIONS, 12 | TOKEN_FILE_CORRUPTED, 13 | TOKEN_FILE_PERMISSIONS, 14 | ) 15 | from ..helpers.utils import get_country 16 | from ..integration.const_integration import DOMAIN 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | class BasePermissions: 22 | """Class in support of building permission sets.""" 23 | 24 | def __init__(self, hass, config, token_backend): 25 | """Initialise the class.""" 26 | self._hass = hass 27 | self._config = config 28 | self._country = get_country(config) 29 | 30 | self._requested_permissions = [] 31 | self._permissions = [] 32 | self.failed_permissions = [] 33 | self.ha_token_backend = token_backend 34 | 35 | @property 36 | def requested_permissions(self): 37 | """Return the required scope.""" 38 | 39 | @property 40 | def permissions(self): 41 | """Return the permission set.""" 42 | return self._permissions 43 | 44 | async def async_check_authorizations(self): 45 | """Report on permissions status.""" 46 | error, self._permissions = await self._hass.async_add_executor_job( 47 | self._get_permissions 48 | ) 49 | 50 | if error in [TOKEN_FILE_CORRUPTED]: 51 | return error 52 | self.failed_permissions = [] 53 | for permission in self.requested_permissions: 54 | if not self.validate_authorization(permission): 55 | self.failed_permissions.append(permission) 56 | 57 | if self.failed_permissions: 58 | _LOGGER.warning( 59 | TOKEN_ERROR_PERMISSIONS, 60 | ", ".join(self.failed_permissions), 61 | self.ha_token_backend.token_filename, 62 | self._config[CONF_ENTITY_NAME], 63 | ) 64 | return TOKEN_FILE_PERMISSIONS 65 | 66 | return False 67 | 68 | def validate_authorization(self, permission): 69 | """Validate higher permissions.""" 70 | if permission in self.permissions: 71 | return True 72 | 73 | if self._check_higher_permissions(permission): 74 | return True 75 | 76 | resource = permission.split(".")[0] 77 | constraint = ( 78 | permission.split(".")[2] if len(permission.split(".")) == 3 else None 79 | ) 80 | 81 | # If Calendar, Contacts or Mail Resource then permissions can have a constraint of .Shared 82 | # which includes base as well. e.g. Calendars.Read is also enabled by Calendars.Read.Shared 83 | if not constraint and resource in ["Calendars", "Contacts", "Mail"]: 84 | sharedpermission = f"{deepcopy(permission)}.Shared" 85 | return self._check_higher_permissions(sharedpermission) 86 | # If Presence Resource then permissions can have a constraint of .All 87 | # which includes base as well. e.g. Presence.Read is also enabled by Presence.Read.All 88 | if not constraint and resource in ["Presence"]: 89 | allpermission = f"{deepcopy(permission)}.All" 90 | return self._check_higher_permissions(allpermission) 91 | 92 | return False 93 | 94 | def _check_higher_permissions(self, permission): 95 | operation = permission.split(".")[1] 96 | # If Operation is ReadBasic then Read or ReadWrite will also work 97 | # If Operation is Read then ReadWrite will also work 98 | newops = [operation] 99 | if operation == "ReadBasic": 100 | newops += ["Read", "ReadWrite"] 101 | elif operation == "Read": 102 | newops += ["ReadWrite"] 103 | 104 | for newop in newops: 105 | newperm = deepcopy(permission).replace(operation, newop) 106 | if newperm in self.permissions: 107 | return True 108 | 109 | return False 110 | 111 | def _get_permissions(self): 112 | """Get the permissions from the token file.""" 113 | 114 | scopes = self.ha_token_backend.token_backend.get_token_scopes() 115 | if scopes is None: 116 | _LOGGER.warning( 117 | TOKEN_ERROR_CORRUPT, 118 | DOMAIN, 119 | self._config[CONF_ENTITY_NAME], 120 | "No permissions", 121 | ) 122 | return TOKEN_FILE_CORRUPTED, None 123 | 124 | prefix = COUNTRY_URLS[self._country][PERMISSION_PREFIX] 125 | for idx, scope in enumerate(scopes): 126 | scopes[idx] = scope.removeprefix(prefix) 127 | 128 | return False, scopes 129 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/services.yaml: -------------------------------------------------------------------------------- 1 | respond_calendar_event: 2 | target: 3 | entity: 4 | integration: ms365_calendar 5 | domain: calendar 6 | fields: 7 | event_id: 8 | example: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 9 | required: true 10 | selector: 11 | text: 12 | response: 13 | example: Decline 14 | required: true 15 | selector: 16 | text: 17 | message: 18 | example: "I cannot attend this meeting" 19 | selector: 20 | text: 21 | send_response: 22 | example: True 23 | selector: 24 | boolean: 25 | 26 | create_calendar_event: 27 | target: 28 | entity: 29 | integration: ms365_calendar 30 | domain: calendar 31 | fields: 32 | subject: 33 | example: Clean up the garage 34 | required: true 35 | selector: 36 | text: 37 | start: 38 | example: "2025-03-22 20:00:00" 39 | required: true 40 | selector: 41 | datetime: 42 | end: 43 | example: "2025-03-22 20:30:00" 44 | required: true 45 | selector: 46 | datetime: 47 | body: 48 | example: Remember to also clean out the gutters 49 | selector: 50 | text: 51 | location: 52 | example: "1600 Pennsylvania Ave Nw, Washington, DC 20500" 53 | selector: 54 | text: 55 | categories: 56 | selector: 57 | text: 58 | sensitivity: 59 | example: normal 60 | selector: 61 | select: 62 | mode: dropdown 63 | options: 64 | - label: "Normal" 65 | value: "normal" 66 | - label: "Personal" 67 | value: "personal" 68 | - label: "Private" 69 | value: "private" 70 | - label: "Confidential" 71 | value: "confidential" 72 | show_as: 73 | example: busy 74 | selector: 75 | select: 76 | mode: dropdown 77 | options: 78 | - label: "Free" 79 | value: "free" 80 | - label: "Tentative" 81 | value: "tentative" 82 | - label: "Busy" 83 | value: "busy" 84 | - label: "Out of Office" 85 | value: "oof" 86 | - label: "Working Elsewhere" 87 | value: "workingElsewhere" 88 | - label: "Unknown" 89 | value: "unknown" 90 | is_all_day: 91 | example: False 92 | selector: 93 | boolean: 94 | attendees: 95 | selector: 96 | object: 97 | 98 | modify_calendar_event: 99 | target: 100 | entity: 101 | integration: ms365_calendar 102 | domain: calendar 103 | fields: 104 | event_id: 105 | example: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 106 | required: true 107 | selector: 108 | text: 109 | subject: 110 | example: Clean up the garage 111 | selector: 112 | text: 113 | start: 114 | example: "2025-03-22 20:00:00" 115 | selector: 116 | datetime: 117 | end: 118 | example: "2025-03-22 20:30:00" 119 | selector: 120 | datetime: 121 | body: 122 | example: Remember to also clean out the gutters 123 | selector: 124 | text: 125 | location: 126 | example: "1600 Pennsylvania Ave Nw, Washington, DC 20500" 127 | selector: 128 | text: 129 | categories: 130 | selector: 131 | text: 132 | sensitivity: 133 | example: normal 134 | selector: 135 | select: 136 | mode: dropdown 137 | options: 138 | - label: "Normal" 139 | value: "normal" 140 | - label: "Personal" 141 | value: "personal" 142 | - label: "Private" 143 | value: "private" 144 | - label: "Confidential" 145 | value: "confidential" 146 | show_as: 147 | example: busy 148 | selector: 149 | select: 150 | mode: dropdown 151 | options: 152 | - label: "Free" 153 | value: "free" 154 | - label: "Tentative" 155 | value: "tentative" 156 | - label: "Busy" 157 | value: "busy" 158 | - label: "Out of Office" 159 | value: "oof" 160 | - label: "Working Elsewhere" 161 | value: "workingElsewhere" 162 | - label: "Unknown" 163 | value: "unknown" 164 | is_all_day: 165 | example: False 166 | selector: 167 | boolean: 168 | attendees: 169 | selector: 170 | object: 171 | 172 | remove_calendar_event: 173 | target: 174 | entity: 175 | integration: ms365_calendar 176 | domain: calendar 177 | fields: 178 | event_id: 179 | example: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 180 | required: true 181 | selector: 182 | text: 183 | 184 | -------------------------------------------------------------------------------- /tests/integration/data_integration/O365/calendar1_calendar_view.json: -------------------------------------------------------------------------------- 1 | { 2 | "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('fake-user-id')/calendars('calendar1')/calendarView(subject,body,attendees,categories,sensitivity,start,seriesMasterId,isAllDay,end,showAs,location)", 3 | "value": [ 4 | { 5 | "@odata.etag": "W/\"qIkRKy24jUSQwhcjI6uJIQAIopRuag==\"", 6 | "id": "event1", 7 | "categories": [], 8 | "reminderMinutesBeforeStart": 30, 9 | "isReminderOn": true, 10 | "subject": "Test event 1 calendar1", 11 | "sensitivity": "normal", 12 | "isAllDay": false, 13 | "seriesMasterId": null, 14 | "showAs": "busy", 15 | "body": { 16 | "contentType": "html", 17 | "content": "\r\n\r\n\r\n\r\n\r\n\r\n\r\n
\r\n
\r\n

 Test

\r\n
\r\n
\r\n\r\n\r\n" 18 | }, 19 | "start": { 20 | "dateTime": "2020-01-01T00:00:00.0000000", 21 | "timeZone": "UTC" 22 | }, 23 | "end": { 24 | "dateTime": "2020-01-02T23:59:59.0000000", 25 | "timeZone": "UTC" 26 | }, 27 | "location": { 28 | "displayName": "Test Location", 29 | "locationUri": "", 30 | "locationType": "default", 31 | "uniqueId": "Test Location", 32 | "uniqueIdType": "private", 33 | "address": { 34 | "street": "", 35 | "city": "", 36 | "state": "", 37 | "countryOrRegion": "", 38 | "postalCode": "" 39 | }, 40 | "coordinates": { 41 | "latitude": 0, 42 | "longitude": 0 43 | } 44 | }, 45 | "attendees": [] 46 | }, 47 | { 48 | "@odata.etag": "W/\"qIkRKy24jUSQwhcjI6uJIQAIopRuag==\"", 49 | "id": "event2", 50 | "categories": [], 51 | "reminderMinutesBeforeStart": 0, 52 | "isReminderOn": false, 53 | "subject": "Test event 2 calendar1", 54 | "sensitivity": "private", 55 | "isAllDay": true, 56 | "seriesMasterId": "master2", 57 | "showAs": "busy", 58 | "body": { 59 | "contentType": "text", 60 | "content": "Plain Text" 61 | }, 62 | "start": { 63 | "dateTime": "2020-01-01T00:00:00.0000000", 64 | "timeZone": "UTC" 65 | }, 66 | "end": { 67 | "dateTime": "2020-01-02T00:00:00.0000000", 68 | "timeZone": "UTC" 69 | }, 70 | "recurrence": { 71 | "pattern": { 72 | "type": "daily", 73 | "interval": 1, 74 | "month": 0, 75 | "dayOfMonth": 0, 76 | "firstDayOfWeek": "sunday", 77 | "index": "first" 78 | }, 79 | "range": { 80 | "type": "endDate", 81 | "startDate": "2022-10-24", 82 | "endDate": "2023-04-24", 83 | "recurrenceTimeZone": "GMT Standard Time", 84 | "numberOfOccurrences": 0 85 | } 86 | }, 87 | "location": { 88 | "displayName": "Test Location", 89 | "locationUri": "", 90 | "locationType": "default", 91 | "uniqueId": "Test Location", 92 | "uniqueIdType": "private", 93 | "address": { 94 | "street": "", 95 | "city": "", 96 | "state": "", 97 | "countryOrRegion": "", 98 | "postalCode": "" 99 | }, 100 | "coordinates": { 101 | "latitude": 0, 102 | "longitude": 0 103 | } 104 | }, 105 | "attendees": [] 106 | } 107 | ] 108 | } -------------------------------------------------------------------------------- /tests/integration/data_integration/O365/calendar1_calendar_view_started.json: -------------------------------------------------------------------------------- 1 | { 2 | "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('fake-user-id')/calendars('calendar1')/calendarView(subject,body,attendees,categories,sensitivity,start,seriesMasterId,isAllDay,end,showAs,location)", 3 | "value": [ 4 | { 5 | "@odata.etag": "W/\"qIkRKy24jUSQwhcjI6uJIQAIopRuag==\"", 6 | "id": "event1", 7 | "categories": [], 8 | "reminderMinutesBeforeStart": 0, 9 | "isReminderOn": false, 10 | "subject": "Test event 1 calendar1", 11 | "sensitivity": "normal", 12 | "isAllDay": true, 13 | "seriesMasterId": null, 14 | "showAs": "busy", 15 | "body": { 16 | "contentType": "html", 17 | "content": "\r\n\r\n\r\n\r\n\r\n\r\n\r\n
\r\n
\r\n

 Test

\r\n
\r\n
\r\n\r\n\r\n" 18 | }, 19 | "start": { 20 | "dateTime": "2020-01-01T00:00:00.0000000", 21 | "timeZone": "UTC" 22 | }, 23 | "end": { 24 | "dateTime": "2020-01-02T00:00:00.0000000", 25 | "timeZone": "UTC" 26 | }, 27 | "location": { 28 | "displayName": "Test Location", 29 | "locationUri": "", 30 | "locationType": "default", 31 | "uniqueId": "Test Location", 32 | "uniqueIdType": "private", 33 | "address": { 34 | "street": "", 35 | "city": "", 36 | "state": "", 37 | "countryOrRegion": "", 38 | "postalCode": "" 39 | }, 40 | "coordinates": { 41 | "latitude": 0, 42 | "longitude": 0 43 | } 44 | }, 45 | "attendees": [] 46 | }, 47 | { 48 | "@odata.etag": "W/\"qIkRKy24jUSQwhcjI6uJIQAIopRuag==\"", 49 | "id": "event2", 50 | "categories": [], 51 | "subject": "Test started", 52 | "sensitivity": "normal", 53 | "isAllDay": false, 54 | "seriesMasterId": null, 55 | "showAs": "busy", 56 | "body": { 57 | "contentType": "html", 58 | "content": "\r\n\r\n\r\n\r\n\r\n\r\n\r\n
\r\n
\r\n

 Test

\r\n
\r\n
\r\n\r\n\r\n" 59 | }, 60 | "start": { 61 | "dateTime": "2020-01-01T00:00:00.0000000", 62 | "timeZone": "UTC" 63 | }, 64 | "end": { 65 | "dateTime": "2020-01-02T23:59:59.0000000", 66 | "timeZone": "UTC" 67 | }, 68 | "location": { 69 | "displayName": "Test Location", 70 | "locationUri": "", 71 | "locationType": "default", 72 | "uniqueId": "Test Location", 73 | "uniqueIdType": "private", 74 | "address": { 75 | "street": "", 76 | "city": "", 77 | "state": "", 78 | "countryOrRegion": "", 79 | "postalCode": "" 80 | }, 81 | "coordinates": { 82 | "latitude": 0, 83 | "longitude": 0 84 | } 85 | }, 86 | "attendees": [] 87 | } 88 | ] 89 | } -------------------------------------------------------------------------------- /custom_components/ms365_calendar/integration/setup_integration.py: -------------------------------------------------------------------------------- 1 | """Do configuration setup.""" 2 | 3 | import logging 4 | import os 5 | 6 | from homeassistant.config_entries import ConfigEntry 7 | from homeassistant.const import CONF_ENTITY_ID, CONF_NAME 8 | from homeassistant.core import HomeAssistant 9 | from requests.exceptions import HTTPError 10 | 11 | from ..classes.config_entry import MS365ConfigEntry 12 | from ..const import CONF_ENTITY_NAME 13 | from .const_integration import ( 14 | CONF_CAL_ID, 15 | CONF_CAN_EDIT, 16 | CONF_DEVICE_ID, 17 | CONF_ENTITIES, 18 | CONF_ENTITY, 19 | CONF_EXCLUDE, 20 | CONF_SEARCH, 21 | CONF_SENSITIVITY_EXCLUDE, 22 | CONF_TRACK, 23 | PLATFORMS, 24 | YAML_CALENDARS_FILENAME, 25 | ) 26 | from .coordinator_integration import ( 27 | MS365CalendarSyncCoordinator, 28 | ) 29 | from .filemgmt_integration import ( 30 | build_yaml_file_path, 31 | build_yaml_filename, 32 | load_yaml_file, 33 | ) 34 | from .schema_integration import YAML_CALENDAR_DEVICE_SCHEMA 35 | from .store_integration import LocalCalendarStore 36 | from .sync.api import MS365CalendarService, async_scan_for_calendars 37 | from .sync.store import ScopedCalendarStore 38 | from .sync.sync import ( 39 | MS365CalendarEventSyncManager, 40 | ) 41 | from .utils_integration import build_calendar_entity_id 42 | 43 | _LOGGER = logging.getLogger(__name__) 44 | 45 | 46 | async def async_do_setup(hass: HomeAssistant, entry: ConfigEntry, account): 47 | """Run the setup after we have everything configured.""" 48 | 49 | scanned_calendars = await async_scan_for_calendars(hass, entry, account) 50 | coordinators, keys = await _async_setup_coordinators( 51 | hass, 52 | account, 53 | entry, 54 | scanned_calendars, 55 | ) 56 | 57 | return coordinators, keys, PLATFORMS 58 | 59 | 60 | async def async_integration_remove_entry(hass: HomeAssistant, entry: MS365ConfigEntry): 61 | """Integration specific entry removal.""" 62 | yaml_filename = build_yaml_filename(entry, YAML_CALENDARS_FILENAME) 63 | yaml_filepath = build_yaml_file_path(hass, yaml_filename) 64 | if os.path.exists(yaml_filepath): 65 | await hass.async_add_executor_job(os.remove, yaml_filepath) 66 | store = LocalCalendarStore(hass, entry.entry_id) 67 | await store.async_remove() 68 | 69 | 70 | async def _async_setup_coordinators( 71 | hass, 72 | account, 73 | entry: MS365ConfigEntry, 74 | scanned_calendars, 75 | ): 76 | yaml_filename = build_yaml_filename(entry, YAML_CALENDARS_FILENAME) 77 | yaml_filepath = build_yaml_file_path(hass, yaml_filename) 78 | calendars = await hass.async_add_executor_job( 79 | load_yaml_file, yaml_filepath, CONF_CAL_ID, YAML_CALENDAR_DEVICE_SCHEMA 80 | ) 81 | 82 | local_store = LocalCalendarStore(hass, entry.entry_id) 83 | 84 | coordinators = [] 85 | keys = [] 86 | for cal_id, calendar in calendars.items(): 87 | for entity in calendar.get(CONF_ENTITIES): 88 | if not entity[CONF_TRACK]: 89 | continue 90 | can_edit = next( 91 | ( 92 | scanned_calendar.can_edit 93 | for scanned_calendar in scanned_calendars 94 | if scanned_calendar.calendar_id == cal_id 95 | ), 96 | True, 97 | ) 98 | entity_id = build_calendar_entity_id( 99 | entity.get(CONF_DEVICE_ID), entry.data[CONF_ENTITY_NAME] 100 | ) 101 | 102 | keys.append( 103 | { 104 | CONF_ENTITY: entity, 105 | CONF_ENTITY_ID: entity_id, 106 | CONF_CAN_EDIT: can_edit, 107 | } 108 | ) 109 | try: 110 | api = MS365CalendarService( 111 | hass, 112 | account, 113 | cal_id, 114 | entity.get(CONF_SENSITIVITY_EXCLUDE), 115 | entity.get(CONF_SEARCH), 116 | entity_id, 117 | ) 118 | if await api.async_calendar_init(): 119 | unique_id = f"{entity.get(CONF_NAME)}" 120 | sync_manager = MS365CalendarEventSyncManager( 121 | api, 122 | cal_id, 123 | store=ScopedCalendarStore(local_store, unique_id), 124 | exclude=entity.get(CONF_EXCLUDE), 125 | ) 126 | coordinators.append( 127 | MS365CalendarSyncCoordinator( 128 | hass, entry, sync_manager, unique_id, entity 129 | ) 130 | ) 131 | except HTTPError: 132 | _LOGGER.warning( 133 | "No permission for calendar, please remove - Name: %s; Device: %s;", 134 | entity[CONF_NAME], 135 | entity[CONF_DEVICE_ID], 136 | ) 137 | continue 138 | 139 | return coordinators, keys 140 | -------------------------------------------------------------------------------- /tests/integration/data_integration/O365/calendar1_calendar_view_all_day.json: -------------------------------------------------------------------------------- 1 | { 2 | "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('fake-user-id')/calendars('calendar1')/calendarView(subject,body,attendees,categories,sensitivity,start,seriesMasterId,isAllDay,end,showAs,location)", 3 | "value": [ 4 | { 5 | "@odata.etag": "W/\"qIkRKy24jUSQwhcjI6uJIQAIopRuag==\"", 6 | "id": "eventall_day", 7 | "categories": [], 8 | "reminderMinutesBeforeStart": 0, 9 | "isReminderOn": false, 10 | "subject": "Test all day", 11 | "sensitivity": "normal", 12 | "isAllDay": true, 13 | "seriesMasterId": null, 14 | "showAs": "busy", 15 | "body": { 16 | "contentType": "html", 17 | "content": "\r\n\r\n\r\n\r\n\r\n\r\n\r\n
\r\n
\r\n

 Test

\r\n
\r\n
\r\n\r\n\r\n" 18 | }, 19 | "start": { 20 | "dateTime": "2020-01-01T00:00:00.0000000", 21 | "timeZone": "UTC" 22 | }, 23 | "end": { 24 | "dateTime": "2020-01-02T00:00:00.0000000", 25 | "timeZone": "UTC" 26 | }, 27 | "location": { 28 | "displayName": "Test Location", 29 | "locationUri": "", 30 | "locationType": "default", 31 | "uniqueId": "Test Location", 32 | "uniqueIdType": "private", 33 | "address": { 34 | "street": "", 35 | "city": "", 36 | "state": "", 37 | "countryOrRegion": "", 38 | "postalCode": "" 39 | }, 40 | "coordinates": { 41 | "latitude": 0, 42 | "longitude": 0 43 | } 44 | }, 45 | "attendees": [] 46 | }, 47 | { 48 | "@odata.etag": "W/\"qIkRKy24jUSQwhcjI6uJIQAIopRuag==\"", 49 | "id": "event1", 50 | "categories": [], 51 | "reminderMinutesBeforeStart": 0, 52 | "isReminderOn": false, 53 | "subject": "Test event 1 calendar1", 54 | "sensitivity": "normal", 55 | "isAllDay": true, 56 | "seriesMasterId": null, 57 | "showAs": "busy", 58 | "body": { 59 | "contentType": "html", 60 | "content": "\r\n\r\n\r\n\r\n\r\n\r\n\r\n
\r\n
\r\n

 Test

\r\n
\r\n
\r\n\r\n\r\n" 61 | }, 62 | "start": { 63 | "dateTime": "2022-01-01T00:00:00.0000000", 64 | "timeZone": "UTC" 65 | }, 66 | "end": { 67 | "dateTime": "2022-01-02T00:00:00.0000000", 68 | "timeZone": "UTC" 69 | }, 70 | "location": { 71 | "displayName": "Test Location", 72 | "locationUri": "", 73 | "locationType": "default", 74 | "uniqueId": "Test Location", 75 | "uniqueIdType": "private", 76 | "address": { 77 | "street": "", 78 | "city": "", 79 | "state": "", 80 | "countryOrRegion": "", 81 | "postalCode": "" 82 | }, 83 | "coordinates": { 84 | "latitude": 0, 85 | "longitude": 0 86 | } 87 | }, 88 | "attendees": [] 89 | } 90 | ] 91 | } -------------------------------------------------------------------------------- /tests/integration/data_integration/O365/calendar1_calendar_view_not_started.json: -------------------------------------------------------------------------------- 1 | { 2 | "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('fake-user-id')/calendars('calendar1')/calendarView(subject,body,attendees,categories,sensitivity,start,seriesMasterId,isAllDay,end,showAs,location)", 3 | "value": [ 4 | { 5 | "@odata.etag": "W/\"qIkRKy24jUSQwhcjI6uJIQAIopRuag==\"", 6 | "id": "event1", 7 | "categories": [], 8 | "reminderMinutesBeforeStart": 0, 9 | "isReminderOn": false, 10 | "subject": "Test event 1 calendar1", 11 | "sensitivity": "normal", 12 | "isAllDay": false, 13 | "seriesMasterId": null, 14 | "showAs": "busy", 15 | "body": { 16 | "contentType": "html", 17 | "content": "\r\n\r\n\r\n\r\n\r\n\r\n\r\n
\r\n
\r\n

 Test

\r\n
\r\n
\r\n\r\n\r\n" 18 | }, 19 | "start": { 20 | "dateTime": "2099-01-01T00:00:00.0000000", 21 | "timeZone": "UTC" 22 | }, 23 | "end": { 24 | "dateTime": "2099-01-02T23:59:59.0000000", 25 | "timeZone": "UTC" 26 | }, 27 | "location": { 28 | "displayName": "Test Location", 29 | "locationUri": "", 30 | "locationType": "default", 31 | "uniqueId": "Test Location", 32 | "uniqueIdType": "private", 33 | "address": { 34 | "street": "", 35 | "city": "", 36 | "state": "", 37 | "countryOrRegion": "", 38 | "postalCode": "" 39 | }, 40 | "coordinates": { 41 | "latitude": 0, 42 | "longitude": 0 43 | } 44 | }, 45 | "attendees": [] 46 | }, 47 | { 48 | "@odata.etag": "W/\"qIkRKy24jUSQwhcjI6uJIQAIopRuag==\"", 49 | "id": "event2", 50 | "categories": [], 51 | "reminderMinutesBeforeStart": 0, 52 | "isReminderOn": false, 53 | "subject": "Test not started", 54 | "sensitivity": "normal", 55 | "isAllDay": false, 56 | "seriesMasterId": null, 57 | "showAs": "busy", 58 | "body": { 59 | "contentType": "html", 60 | "content": "\r\n\r\n\r\n\r\n\r\n\r\n\r\n
\r\n
\r\n

 Test

\r\n
\r\n
\r\n\r\n\r\n" 61 | }, 62 | "start": { 63 | "dateTime": "2020-01-01T23:59:58.0000000", 64 | "timeZone": "UTC" 65 | }, 66 | "end": { 67 | "dateTime": "2020-01-01T23:59:59.0000000", 68 | "timeZone": "UTC" 69 | }, 70 | "location": { 71 | "displayName": "Test Location", 72 | "locationUri": "", 73 | "locationType": "default", 74 | "uniqueId": "Test Location", 75 | "uniqueIdType": "private", 76 | "address": { 77 | "street": "", 78 | "city": "", 79 | "state": "", 80 | "countryOrRegion": "", 81 | "postalCode": "" 82 | }, 83 | "coordinates": { 84 | "latitude": 0, 85 | "longitude": 0 86 | } 87 | }, 88 | "attendees": [] 89 | } 90 | ] 91 | } -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=unused-argument 2 | """Test setup process.""" 3 | 4 | from unittest.mock import patch 5 | 6 | import pytest 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers import entity_registry as er 9 | from homeassistant.helpers import issue_registry as ir 10 | from oauthlib.oauth2.rfc6749.errors import InvalidClientError 11 | from requests_mock import Mocker 12 | 13 | from .const import ENTITY_NAME, TOKEN_LOCATION 14 | from .helpers.mock_config_entry import MS365MockConfigEntry 15 | from .integration.const_integration import DOMAIN, FULL_INIT_ENTITY_NO 16 | from .integration.helpers_integration.mocks import MS365MOCKS 17 | 18 | 19 | async def test_full_init( 20 | hass: HomeAssistant, 21 | base_config_entry: MS365MockConfigEntry, 22 | base_token, 23 | requests_mock: Mocker, 24 | entity_registry: er.EntityRegistry, 25 | ): 26 | """Test full MS365 initialisation.""" 27 | MS365MOCKS.standard_mocks(requests_mock) 28 | 29 | base_config_entry.add_to_hass(hass) 30 | await hass.config_entries.async_setup(base_config_entry.entry_id) 31 | assert hasattr(base_config_entry.runtime_data, "options") 32 | 33 | entities = er.async_entries_for_config_entry( 34 | entity_registry, base_config_entry.entry_id 35 | ) 36 | assert len(entities) == FULL_INIT_ENTITY_NO 37 | 38 | 39 | async def test_invalid_client_1( 40 | hass: HomeAssistant, 41 | requests_mock: Mocker, 42 | base_token, 43 | base_config_entry: MS365MockConfigEntry, 44 | caplog: pytest.LogCaptureFixture, 45 | ) -> None: 46 | """Test an invalid client.""" 47 | MS365MOCKS.standard_mocks(requests_mock) 48 | base_config_entry.add_to_hass(hass) 49 | 50 | with patch( 51 | "O365.Account.get_current_user_data", 52 | side_effect=InvalidClientError(description="client secret expired"), 53 | ): 54 | await hass.config_entries.async_setup(base_config_entry.entry_id) 55 | await hass.async_block_till_done() 56 | assert "Client Secret expired for account" in caplog.text 57 | 58 | 59 | async def test_invalid_client_2( 60 | hass: HomeAssistant, 61 | requests_mock: Mocker, 62 | base_token, 63 | base_config_entry: MS365MockConfigEntry, 64 | caplog: pytest.LogCaptureFixture, 65 | ) -> None: 66 | """Test an invalid client.""" 67 | MS365MOCKS.standard_mocks(requests_mock) 68 | base_config_entry.add_to_hass(hass) 69 | 70 | with patch( 71 | "O365.Account.get_current_user_data", 72 | side_effect=InvalidClientError(description="token error"), 73 | ): 74 | await hass.config_entries.async_setup(base_config_entry.entry_id) 75 | await hass.async_block_till_done() 76 | assert "Token error for account" in caplog.text 77 | 78 | 79 | async def test_legacy_token( 80 | tmp_path, 81 | hass: HomeAssistant, 82 | base_config_entry: MS365MockConfigEntry, 83 | requests_mock: Mocker, 84 | legacy_token, 85 | caplog: pytest.LogCaptureFixture, 86 | issue_registry: ir.IssueRegistry, 87 | ): 88 | """Test with legacy token.""" 89 | 90 | MS365MOCKS.standard_mocks(requests_mock) 91 | 92 | base_config_entry.add_to_hass(hass) 93 | await hass.config_entries.async_setup(base_config_entry.entry_id) 94 | assert f"Token no longer valid for integration '{DOMAIN}'" in caplog.text 95 | assert len(issue_registry.issues) == 1 96 | 97 | 98 | async def test_remove_entry( 99 | tmp_path, 100 | setup_base_integration, 101 | hass: HomeAssistant, 102 | base_config_entry: MS365MockConfigEntry, 103 | ): 104 | """Test removal of entry.""" 105 | 106 | assert await hass.config_entries.async_remove(base_config_entry.entry_id) 107 | await hass.async_block_till_done() 108 | filename = tmp_path / TOKEN_LOCATION / f"{DOMAIN}_{ENTITY_NAME}.token" 109 | assert not filename.is_file() 110 | 111 | 112 | async def test_expired_token( 113 | hass: HomeAssistant, 114 | requests_mock: Mocker, 115 | base_token, 116 | base_config_entry: MS365MockConfigEntry, 117 | caplog: pytest.LogCaptureFixture, 118 | ) -> None: 119 | """Test an invalid client.""" 120 | MS365MOCKS.standard_mocks(requests_mock) 121 | base_config_entry.add_to_hass(hass) 122 | 123 | with patch( 124 | "O365.Account.get_current_user_data", 125 | side_effect=RuntimeError("Refresh token operation failed: invalid_grant"), 126 | ): 127 | await hass.config_entries.async_setup(base_config_entry.entry_id) 128 | await hass.async_block_till_done() 129 | assert ( 130 | "Token has expired for account: 'test'. Please re-configure and re-authenticate." 131 | in caplog.text 132 | ) 133 | 134 | 135 | async def test_other_runtime_error( 136 | hass: HomeAssistant, 137 | requests_mock: Mocker, 138 | base_token, 139 | base_config_entry: MS365MockConfigEntry, 140 | caplog: pytest.LogCaptureFixture, 141 | ) -> None: 142 | """Test an invalid client.""" 143 | MS365MOCKS.standard_mocks(requests_mock) 144 | base_config_entry.add_to_hass(hass) 145 | error = "Random error" 146 | with patch( 147 | "O365.Account.get_current_user_data", 148 | side_effect=RuntimeError(error), 149 | ): 150 | await hass.config_entries.async_setup(base_config_entry.entry_id) 151 | await hass.async_block_till_done() 152 | assert error in caplog.text 153 | -------------------------------------------------------------------------------- /tests/integration/helpers_integration/mocks.py: -------------------------------------------------------------------------------- 1 | """Mock setup.""" 2 | 3 | from datetime import timedelta 4 | 5 | from ...helpers.utils import mock_call, utcnow 6 | from ..const_integration import CN21VURL, URL 7 | 8 | 9 | class MS365Mocks: 10 | """Standard mocks.""" 11 | 12 | def standard_mocks(self, requests_mock): 13 | """Setup the standard mocks.""" 14 | mock_call(requests_mock, URL.OPENID, "openid") 15 | mock_call(requests_mock, URL.ME, "me") 16 | mock_call(requests_mock, URL.CALENDARS, "calendars") 17 | mock_call(requests_mock, URL.CALENDARS, "calendar1", "calendar1") 18 | mock_call( 19 | requests_mock, 20 | URL.CALENDARS, 21 | "calendar1_calendar_view", 22 | "calendar1/calendarView", 23 | start=(utcnow() - timedelta(days=1)).strftime("%Y-%m-%d"), 24 | end=(utcnow() + timedelta(days=1)).strftime("%Y-%m-%d"), 25 | ) 26 | mock_call(requests_mock, URL.CALENDARS, "calendar2", "group:calendar2") 27 | mock_call( 28 | requests_mock, 29 | URL.GROUP_CALENDARS, 30 | "calendar2_calendar_view", 31 | "calendar2/calendar/calendarView", 32 | start=(utcnow() + timedelta(days=1)).strftime("%Y-%m-%d"), 33 | end=(utcnow() + timedelta(days=2)).strftime("%Y-%m-%d"), 34 | ) 35 | mock_call(requests_mock, URL.CALENDARS, "calendar3", "calendar3") 36 | mock_call( 37 | requests_mock, 38 | URL.CALENDARS, 39 | "calendar3_calendar_view", 40 | "calendar3/calendarView", 41 | start=utcnow().strftime("%Y-%m-%d"), 42 | end=(utcnow() + timedelta(days=1)).strftime("%Y-%m-%d"), 43 | ) 44 | 45 | def cn21v_mocks(self, requests_mock): 46 | """Setup the standard mocks.""" 47 | mock_call(requests_mock, CN21VURL.DISCOVERY, "discovery") 48 | mock_call(requests_mock, CN21VURL.OPENID, "openid") 49 | mock_call(requests_mock, CN21VURL.ME, "me") 50 | mock_call(requests_mock, CN21VURL.CALENDARS, "calendars") 51 | mock_call(requests_mock, CN21VURL.CALENDARS, "calendar1", "calendar1") 52 | mock_call(requests_mock, CN21VURL.CALENDARS, "calendar3", "calendar3") 53 | 54 | def shared_mocks(self, requests_mock): 55 | """Setup the standard mocks.""" 56 | mock_call(requests_mock, URL.OPENID, "openid") 57 | mock_call(requests_mock, URL.ME, "me") 58 | mock_call(requests_mock, URL.SHARED_CALENDARS, "calendars") 59 | mock_call(requests_mock, URL.SHARED_CALENDARS, "calendar1", "calendar1") 60 | mock_call( 61 | requests_mock, 62 | URL.SHARED_CALENDARS, 63 | "calendar1_calendar_view", 64 | "calendar1/calendarView", 65 | start=utcnow().strftime("%Y-%m-%d"), 66 | end=(utcnow() + timedelta(days=1)).strftime("%Y-%m-%d"), 67 | ) 68 | mock_call(requests_mock, URL.CALENDARS, "calendar2", "group:calendar2") 69 | mock_call( 70 | requests_mock, 71 | URL.GROUP_CALENDARS, 72 | "calendar2_calendar_view", 73 | "calendar2/calendar/calendarView", 74 | ) 75 | mock_call(requests_mock, URL.SHARED_CALENDARS, "calendar3", "calendar3") 76 | mock_call( 77 | requests_mock, 78 | URL.SHARED_CALENDARS, 79 | "calendar3_calendar_view", 80 | "calendar3/calendarView", 81 | start=utcnow().strftime("%Y-%m-%d"), 82 | end=(utcnow() + timedelta(days=1)).strftime("%Y-%m-%d"), 83 | ) 84 | 85 | def no_events_mocks(self, requests_mock): 86 | """Setup the standard mocks.""" 87 | _generic_mocks(requests_mock) 88 | mock_call( 89 | requests_mock, 90 | URL.CALENDARS, 91 | "calendar1_calendar_view_none", 92 | "calendar1/calendarView", 93 | start=utcnow().strftime("%Y-%m-%d"), 94 | end=(utcnow() + timedelta(days=1)).strftime("%Y-%m-%d"), 95 | ) 96 | 97 | def all_day_event_mocks(self, requests_mock): 98 | """Setup the standard mocks.""" 99 | _generic_mocks(requests_mock) 100 | mock_call( 101 | requests_mock, 102 | URL.CALENDARS, 103 | "calendar1_calendar_view_all_day", 104 | "calendar1/calendarView", 105 | start=(utcnow() - timedelta(days=1)).strftime("%Y-%m-%d"), 106 | end=(utcnow() + timedelta(days=1)).strftime("%Y-%m-%d"), 107 | ) 108 | 109 | def started_event_mocks(self, requests_mock): 110 | """Setup the standard mocks.""" 111 | _generic_mocks(requests_mock) 112 | mock_call( 113 | requests_mock, 114 | URL.CALENDARS, 115 | "calendar1_calendar_view_started", 116 | "calendar1/calendarView", 117 | start=utcnow().strftime("%Y-%m-%d"), 118 | end=(utcnow() + timedelta(days=1)).strftime("%Y-%m-%d"), 119 | ) 120 | 121 | def not_started_event_mocks(self, requests_mock): 122 | """Setup the standard mocks.""" 123 | _generic_mocks(requests_mock) 124 | mock_call( 125 | requests_mock, 126 | URL.CALENDARS, 127 | "calendar1_calendar_view_not_started", 128 | "calendar1/calendarView", 129 | start=utcnow().strftime("%Y-%m-%d"), 130 | end=(utcnow() + timedelta(days=1)).strftime("%Y-%m-%d"), 131 | ) 132 | 133 | 134 | MS365MOCKS = MS365Mocks() 135 | 136 | 137 | def _generic_mocks(requests_mock): 138 | mock_call(requests_mock, URL.OPENID, "openid") 139 | mock_call(requests_mock, URL.ME, "me") 140 | mock_call(requests_mock, URL.CALENDARS, "calendars_one") 141 | mock_call(requests_mock, URL.CALENDARS, "calendar1", "calendar1") 142 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/integration/utils_integration.py: -------------------------------------------------------------------------------- 1 | """Calendar utilities processes.""" 2 | 3 | import logging 4 | from datetime import datetime 5 | 6 | from dateutil import parser 7 | from homeassistant.util import slugify 8 | from O365.calendar import Attendee # pylint: disable=no-name-in-module) 9 | 10 | from ..helpers.utils import clean_html 11 | from .const_integration import ( 12 | ATTR_ATTENDEES, 13 | ATTR_BODY, 14 | ATTR_CATEGORIES, 15 | ATTR_IS_ALL_DAY, 16 | ATTR_LOCATION, 17 | ATTR_RRULE, 18 | ATTR_SENSITIVITY, 19 | ATTR_SHOW_AS, 20 | CALENDAR_ENTITY_ID_FORMAT, 21 | DAYS, 22 | INDEXES, 23 | ) 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | 28 | def format_event_data(event): 29 | """Format the event data.""" 30 | # if hasattr(event.attendees, "attendees"): 31 | # attendees = event.attendees.attendees 32 | # else: 33 | attendees = event.attendees._Attendees__attendees # pylint: disable=protected-access 34 | return { 35 | "summary": event.subject, 36 | "start": get_hass_date(event.start, event.is_all_day), 37 | "end": get_hass_date(get_end_date(event), event.is_all_day), 38 | "all_day": event.is_all_day, 39 | "description": clean_html(event.body), 40 | "location": event.location["displayName"], 41 | "categories": event.categories, 42 | "sensitivity": event.sensitivity.name, 43 | "show_as": event.show_as.name, 44 | "reminder": { 45 | "minutes": event.remind_before_minutes, 46 | "is_on": event.is_reminder_on, 47 | }, 48 | "attendees": [ 49 | {"email": x.address, "type": x.attendee_type.value} for x in attendees 50 | ], 51 | "uid": event.object_id, 52 | } 53 | 54 | 55 | def get_hass_date(obj, is_all_day): 56 | """Get the date.""" 57 | return obj if isinstance(obj, datetime) and not is_all_day else obj.date() 58 | 59 | 60 | def get_end_date(obj): 61 | """Get the end date.""" 62 | return obj.end 63 | 64 | 65 | def get_start_date(obj): 66 | """Get the start date.""" 67 | return obj.start 68 | 69 | 70 | def add_call_data_to_event(event, subject, start, end, **kwargs): 71 | """Add the call data.""" 72 | event.subject = _add_attribute(subject, event.subject) 73 | event.body = _add_attribute(kwargs.get(ATTR_BODY, None), event.body) 74 | event.location = _add_attribute(kwargs.get(ATTR_LOCATION, None), event.location) 75 | event.categories = _add_attribute(kwargs.get(ATTR_CATEGORIES, []), event.categories) 76 | event.show_as = _add_attribute(kwargs.get(ATTR_SHOW_AS, None), event.show_as) 77 | event.start = _add_attribute(start, event.start) 78 | event.end = _add_attribute(end, event.end) 79 | event.sensitivity = _add_attribute( 80 | kwargs.get(ATTR_SENSITIVITY, None), event.sensitivity 81 | ) 82 | _add_attendees(kwargs.get(ATTR_ATTENDEES, []), event) 83 | _add_all_day(kwargs.get(ATTR_IS_ALL_DAY, False), event) 84 | 85 | if kwargs.get(ATTR_RRULE, None): 86 | _rrule_processing(event, kwargs[ATTR_RRULE]) 87 | return event 88 | 89 | 90 | def _add_attribute(attribute, event_attribute): 91 | return attribute or event_attribute 92 | 93 | 94 | def _add_attendees(attendees, event): 95 | if attendees: 96 | event.attendees.clear() 97 | event.attendees.add( 98 | [ 99 | Attendee(x["email"], attendee_type=x["type"], event=event) 100 | for x in attendees 101 | ] 102 | ) 103 | 104 | 105 | def _add_all_day(is_all_day, event): 106 | if is_all_day is not None: 107 | event.is_all_day = is_all_day 108 | if event.is_all_day: 109 | event.start = datetime( 110 | event.start.year, event.start.month, event.start.day, 0, 0, 0 111 | ) 112 | event.end = datetime( 113 | event.end.year, event.end.month, event.end.day, 0, 0, 0 114 | ) 115 | 116 | 117 | def _rrule_processing(event, rrule): 118 | rules = {} 119 | for item in rrule.split(";"): 120 | keys = item.split("=") 121 | rules[keys[0]] = keys[1] 122 | 123 | kwargs = {} 124 | if "COUNT" in rules: 125 | kwargs["occurrences"] = int(rules["COUNT"]) 126 | if "UNTIL" in rules: 127 | end = parser.parse(rules["UNTIL"]) 128 | end.replace(tzinfo=event.start.tzinfo) 129 | kwargs["end"] = end 130 | interval = int(rules["INTERVAL"]) if "INTERVAL" in rules else 1 131 | if "BYDAY" in rules: 132 | days, index = _process_byday(rules["BYDAY"]) 133 | kwargs["days_of_week"] = days 134 | if index: 135 | kwargs["index"] = index 136 | 137 | if rules["FREQ"] == "YEARLY": 138 | kwargs["day_of_month"] = event.start.day 139 | event.recurrence.set_yearly(interval, event.start.month, **kwargs) 140 | 141 | if rules["FREQ"] == "MONTHLY": 142 | if "BYDAY" not in rules: 143 | kwargs["day_of_month"] = event.start.day 144 | event.recurrence.set_monthly(interval, **kwargs) 145 | 146 | if rules["FREQ"] == "WEEKLY": 147 | kwargs["first_day_of_week"] = "sunday" 148 | event.recurrence.set_weekly(interval, **kwargs) 149 | 150 | if rules["FREQ"] == "DAILY": 151 | event.recurrence.set_daily(interval, **kwargs) 152 | 153 | 154 | def _process_byday(byday): 155 | days = [] 156 | for item in byday.split(","): 157 | if len(item) > 2: 158 | days.append(DAYS[item[2:4]]) 159 | index = INDEXES[item[:2]] 160 | else: 161 | days.append(DAYS[item[:2]]) 162 | index = None 163 | return days, index 164 | 165 | 166 | def build_calendar_entity_id(device_id, entity_name): 167 | """Build calendar entity_id.""" 168 | name = f"{entity_name}_{device_id}" 169 | return CALENDAR_ENTITY_ID_FORMAT.format(slugify(name)) 170 | -------------------------------------------------------------------------------- /tests/integration/test_config_flow.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=line-too-long, unused-argument 2 | """Test the config flow.""" 3 | 4 | from copy import deepcopy 5 | from unittest.mock import MagicMock, patch 6 | 7 | import pytest 8 | from homeassistant import config_entries 9 | from homeassistant.const import CONF_NAME 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.data_entry_flow import FlowResultType 12 | from requests_mock import Mocker 13 | 14 | from custom_components.ms365_calendar.integration.const_integration import ( 15 | CONF_ADVANCED_OPTIONS, 16 | CONF_CALENDAR_LIST, 17 | CONF_DAYS_BACKWARD, 18 | CONF_DAYS_FORWARD, 19 | CONF_HOURS_BACKWARD_TO_GET, 20 | CONF_HOURS_FORWARD_TO_GET, 21 | CONF_MAX_RESULTS, 22 | CONF_TRACK_NEW_CALENDAR, 23 | CONF_UPDATE_INTERVAL, 24 | DEFAULT_DAYS_BACKWARD, 25 | DEFAULT_DAYS_FORWARD, 26 | DEFAULT_UPDATE_INTERVAL, 27 | ) 28 | 29 | from ..helpers.mock_config_entry import MS365MockConfigEntry 30 | from ..helpers.utils import build_token_url, get_schema_default, mock_token 31 | from .const_integration import ( 32 | AUTH_CALLBACK_PATH_DEFAULT, 33 | BASE_CONFIG_ENTRY, 34 | DOMAIN, 35 | SHARED_TOKEN_PERMS, 36 | UPDATE_CALENDAR_LIST, 37 | ) 38 | from .helpers_integration.mocks import MS365MOCKS 39 | 40 | 41 | async def test_options_flow( 42 | hass: HomeAssistant, 43 | setup_base_integration, 44 | base_config_entry: MS365MockConfigEntry, 45 | ) -> None: 46 | """Test the options flow""" 47 | 48 | result = await hass.config_entries.options.async_init(base_config_entry.entry_id) 49 | await hass.async_block_till_done() 50 | 51 | assert result["type"] is FlowResultType.FORM 52 | assert result["step_id"] == "user" 53 | schema = result["data_schema"].schema 54 | assert get_schema_default(schema, CONF_TRACK_NEW_CALENDAR) is True 55 | assert get_schema_default(schema, CONF_CALENDAR_LIST) == [ 56 | "Calendar1", 57 | "Calendar2", 58 | "Calendar3", 59 | ] 60 | 61 | result = await hass.config_entries.options.async_configure( 62 | result["flow_id"], 63 | user_input={ 64 | CONF_TRACK_NEW_CALENDAR: False, 65 | CONF_CALENDAR_LIST: UPDATE_CALENDAR_LIST, 66 | CONF_ADVANCED_OPTIONS: { 67 | CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, 68 | CONF_DAYS_BACKWARD: DEFAULT_DAYS_BACKWARD, 69 | CONF_DAYS_FORWARD: DEFAULT_DAYS_FORWARD, 70 | }, 71 | }, 72 | ) 73 | await hass.async_block_till_done() 74 | assert result["type"] is FlowResultType.FORM 75 | assert result["step_id"] == "calendar_config" 76 | assert result["last_step"] is True 77 | schema = result["data_schema"].schema 78 | assert get_schema_default(schema, CONF_NAME) == "Calendar1" 79 | assert get_schema_default(schema, CONF_HOURS_FORWARD_TO_GET) == 24 80 | assert get_schema_default(schema, CONF_HOURS_BACKWARD_TO_GET) == 0 81 | assert get_schema_default(schema, CONF_MAX_RESULTS) is None 82 | 83 | result = await hass.config_entries.options.async_configure( 84 | result["flow_id"], 85 | user_input={ 86 | CONF_NAME: "Calendar1_Changed", 87 | CONF_HOURS_FORWARD_TO_GET: 48, 88 | CONF_HOURS_BACKWARD_TO_GET: -48, 89 | CONF_MAX_RESULTS: 5, 90 | }, 91 | ) 92 | await hass.async_block_till_done() 93 | assert result.get("type") is FlowResultType.CREATE_ENTRY 94 | 95 | assert result["data"][CONF_TRACK_NEW_CALENDAR] is False 96 | 97 | assert result["data"][CONF_CALENDAR_LIST] == UPDATE_CALENDAR_LIST 98 | 99 | 100 | @pytest.mark.parametrize( 101 | "base_config_entry", 102 | [{"basic_calendar": True, "enable_update": True}], 103 | indirect=True, 104 | ) 105 | async def test_invalid_entry( 106 | hass: HomeAssistant, 107 | requests_mock: Mocker, 108 | base_token, 109 | base_config_entry: MS365MockConfigEntry, 110 | caplog: pytest.LogCaptureFixture, 111 | ) -> None: 112 | """Test for invalid config mix.""" 113 | MS365MOCKS.standard_mocks(requests_mock) 114 | base_config_entry.add_to_hass(hass) 115 | await hass.config_entries.async_setup(base_config_entry.entry_id) 116 | await hass.async_block_till_done() 117 | 118 | assert ( 119 | "'enable_update' should not be true when 'basic_calendar' is true" 120 | in caplog.text 121 | ) 122 | 123 | 124 | async def test_shared_email_invalid( 125 | hass: HomeAssistant, 126 | requests_mock: Mocker, 127 | caplog: pytest.LogCaptureFixture, 128 | ) -> None: 129 | """Test for invalid shared mailbox.""" 130 | mock_token(requests_mock, SHARED_TOKEN_PERMS) 131 | MS365MOCKS.standard_mocks(requests_mock) 132 | 133 | result = await hass.config_entries.flow.async_init( 134 | DOMAIN, context={"source": config_entries.SOURCE_USER} 135 | ) 136 | 137 | user_input = deepcopy(BASE_CONFIG_ENTRY) 138 | email = "john@nomail.com" 139 | user_input["shared_mailbox"] = email 140 | result = await hass.config_entries.flow.async_configure( 141 | result["flow_id"], 142 | user_input=user_input, 143 | ) 144 | 145 | with patch( 146 | f"custom_components.{DOMAIN}.classes.api.MS365CustomAccount", 147 | return_value=mock_account(email), 148 | ): 149 | result = await hass.config_entries.flow.async_configure( 150 | result["flow_id"], 151 | user_input={ 152 | "url": build_token_url(result, AUTH_CALLBACK_PATH_DEFAULT), 153 | }, 154 | ) 155 | 156 | assert result["type"] is FlowResultType.CREATE_ENTRY 157 | 158 | assert ( 159 | f"Login email address '{email}' should not be entered as shared email address, config attribute removed" 160 | in caplog.text 161 | ) 162 | 163 | 164 | def mock_account(email): 165 | """Mock the account.""" 166 | return MagicMock(is_authenticated=True, username=email, main_resource=email) 167 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/__init__.py: -------------------------------------------------------------------------------- 1 | """Main initialisation code.""" 2 | 3 | import logging 4 | 5 | from homeassistant.core import HomeAssistant 6 | from homeassistant.helpers import issue_registry as ir 7 | from homeassistant.helpers.network import get_url 8 | from oauthlib.oauth2.rfc6749.errors import InvalidClientError 9 | 10 | from .classes.api import MS365Account, MS365Token 11 | from .classes.config_entry import MS365ConfigEntry, MS365Data 12 | from .const import ( 13 | CONF_CLIENT_ID, 14 | CONF_CLIENT_SECRET, 15 | CONF_ENTITY_NAME, 16 | CONF_SHARED_MAILBOX, 17 | SECRET_EXPIRED, 18 | TOKEN_DELETED, 19 | TOKEN_ERROR, 20 | TOKEN_EXPIRED, 21 | TOKEN_FILE_EXPIRED, 22 | TOKEN_FILE_MISSING, 23 | ) 24 | from .integration import setup_integration 25 | from .integration.const_integration import DOMAIN, PLATFORMS 26 | from .integration.permissions_integration import Permissions 27 | 28 | _LOGGER = logging.getLogger(__name__) 29 | 30 | 31 | async def async_setup_entry(hass: HomeAssistant, entry: MS365ConfigEntry): 32 | """Set up a config entry.""" 33 | 34 | credentials = ( 35 | entry.data.get(CONF_CLIENT_ID), 36 | entry.data.get(CONF_CLIENT_SECRET), 37 | ) 38 | entity_name = entry.data.get(CONF_ENTITY_NAME) 39 | main_resource = entry.data.get(CONF_SHARED_MAILBOX) 40 | 41 | _LOGGER.debug("Permissions setup") 42 | token_backend = MS365Token(hass, entry.data) 43 | perms = Permissions(hass, entry.data, token_backend) 44 | ha_account = MS365Account(perms, entry.data) 45 | if token_backend.check_token_exists(): 46 | error = ( 47 | await hass.async_add_executor_job( 48 | ha_account.try_authentication, credentials, main_resource, entity_name 49 | ) 50 | or await perms.async_check_authorizations() 51 | ) 52 | else: 53 | error = TOKEN_FILE_MISSING 54 | 55 | if not error: 56 | _LOGGER.debug("Do setup") 57 | check_token = await _async_check_token(hass, ha_account.account, entity_name) 58 | if check_token: 59 | coordinator, sensors, platforms = await setup_integration.async_do_setup( 60 | hass, entry, ha_account.account 61 | ) 62 | entry.runtime_data = MS365Data( 63 | perms, 64 | ha_account, 65 | coordinator, 66 | sensors, 67 | entry.options, 68 | ) 69 | await hass.config_entries.async_forward_entry_setups(entry, platforms) 70 | entry.async_on_unload(entry.add_update_listener(async_reload_entry)) 71 | return True 72 | else: 73 | error = TOKEN_FILE_EXPIRED 74 | 75 | url = f"{get_url(hass)}/config/integrations/integration/{DOMAIN}" 76 | ir.async_create_issue( 77 | hass, 78 | DOMAIN, 79 | error, 80 | is_fixable=False, 81 | severity=ir.IssueSeverity.ERROR, 82 | translation_key=error, 83 | translation_placeholders={ 84 | "domain": DOMAIN, 85 | "url": url, 86 | CONF_ENTITY_NAME: entry.data.get(CONF_ENTITY_NAME), 87 | }, 88 | ) 89 | return False 90 | 91 | 92 | async def async_migrate_entry( 93 | hass: HomeAssistant, config_entry: MS365ConfigEntry 94 | ) -> bool: 95 | """Migrate old entry.""" 96 | _LOGGER.debug( 97 | "Migrating configuration from version %s.%s", 98 | config_entry.version, 99 | config_entry.minor_version, 100 | ) 101 | 102 | # if config_entry.version > 2: 103 | # # This shouldn't happen since we are at v2 104 | # return False 105 | 106 | if config_entry.version == 1: 107 | # Delete the token file ready for re-auth 108 | new_data = {**config_entry.data} 109 | token_backend = MS365Token(hass, config_entry.data) 110 | await hass.async_add_executor_job(token_backend.delete_token) 111 | _LOGGER.warning( 112 | TOKEN_DELETED, 113 | token_backend.token_filename, 114 | ) 115 | hass.config_entries.async_update_entry( 116 | config_entry, data=new_data, minor_version=0, version=2 117 | ) 118 | 119 | _LOGGER.debug( 120 | "Migration to configuration version %s.%s successful", 121 | config_entry.version, 122 | config_entry.minor_version, 123 | ) 124 | 125 | return True 126 | 127 | 128 | async def async_unload_entry(hass: HomeAssistant, entry: MS365ConfigEntry) -> bool: 129 | """Unload a config entry.""" 130 | return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 131 | 132 | 133 | async def async_reload_entry(hass: HomeAssistant, entry: MS365ConfigEntry) -> None: 134 | """Handle options update - only reload if the options have changed.""" 135 | if entry.runtime_data.options != entry.options: 136 | await hass.config_entries.async_reload(entry.entry_id) 137 | 138 | 139 | async def async_remove_entry(hass: HomeAssistant, entry: MS365ConfigEntry) -> None: 140 | """Handle removal of an entry.""" 141 | token_backend = MS365Token(hass, entry.data) 142 | await hass.async_add_executor_job(token_backend.delete_token) 143 | if not hasattr(setup_integration, "async_integration_remove_entry"): 144 | return 145 | await setup_integration.async_integration_remove_entry(hass, entry) 146 | 147 | 148 | async def _async_check_token(hass, account, entity_name): 149 | try: 150 | account = await hass.async_add_executor_job(account.get_current_user_data) 151 | _LOGGER.info("Logged in user: %s, %s", account.full_name, account.object_id) 152 | return True 153 | except InvalidClientError as err: 154 | if "client secret" in err.description and "expired" in err.description: 155 | _LOGGER.warning(SECRET_EXPIRED, entity_name) 156 | else: 157 | _LOGGER.warning(TOKEN_ERROR, entity_name, err.description) 158 | return False 159 | except RuntimeError as err: 160 | if "Refresh token operation failed: invalid_grant" in str(err): 161 | _LOGGER.warning(TOKEN_EXPIRED, entity_name) 162 | return False 163 | raise err 164 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/integration/schema_integration.py: -------------------------------------------------------------------------------- 1 | """Schema for MS365 Integration.""" 2 | 3 | import datetime 4 | from collections.abc import Callable 5 | from itertools import groupby 6 | from typing import Any 7 | 8 | import homeassistant.helpers.config_validation as cv 9 | import voluptuous as vol 10 | from homeassistant.const import CONF_NAME 11 | from homeassistant.util import dt as dt_util 12 | from O365.calendar import ( # pylint: disable=no-name-in-module 13 | AttendeeType, 14 | EventSensitivity, 15 | EventShowAs, 16 | ) 17 | 18 | from ..const import ( 19 | CONF_ENABLE_UPDATE, 20 | CONF_SHARED_MAILBOX, 21 | ) 22 | from .const_integration import ( 23 | ATTR_ATTENDEES, 24 | ATTR_BODY, 25 | ATTR_CATEGORIES, 26 | ATTR_EMAIL, 27 | ATTR_END, 28 | ATTR_EVENT_ID, 29 | ATTR_IS_ALL_DAY, 30 | ATTR_LOCATION, 31 | ATTR_MESSAGE, 32 | ATTR_RESPONSE, 33 | ATTR_SEND_RESPONSE, 34 | ATTR_SENSITIVITY, 35 | ATTR_SHOW_AS, 36 | ATTR_START, 37 | ATTR_SUBJECT, 38 | ATTR_TYPE, 39 | CONF_BASIC_CALENDAR, 40 | CONF_CAL_ID, 41 | CONF_DEVICE_ID, 42 | CONF_ENTITIES, 43 | CONF_EXCLUDE, 44 | CONF_GROUPS, 45 | CONF_HOURS_BACKWARD_TO_GET, 46 | CONF_HOURS_FORWARD_TO_GET, 47 | CONF_MAX_RESULTS, 48 | CONF_SEARCH, 49 | CONF_SENSITIVITY_EXCLUDE, 50 | CONF_TRACK, 51 | EventResponse, 52 | ) 53 | 54 | 55 | def _has_consistent_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: 56 | """Verify that all datetime values have a consistent timezone.""" 57 | 58 | def validate(obj: dict[str, Any]) -> dict[str, Any]: 59 | """Test that all keys that are datetime values have the same timezone.""" 60 | tzinfos = [] 61 | for key in keys: 62 | tzinfos.append(obj.get(key).tzinfo) 63 | uniq_values = groupby(tzinfos) 64 | if len(list(uniq_values)) > 1: 65 | raise vol.Invalid("Expected all values to have the same timezone") 66 | return obj 67 | 68 | return validate 69 | 70 | 71 | def _as_local_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: 72 | """Convert all datetime values to the local timezone.""" 73 | 74 | def validate(obj: dict[str, Any]) -> dict[str, Any]: 75 | """Convert all keys that are datetime values to local timezone.""" 76 | for k in keys: 77 | if (value := obj.get(k)) and isinstance(value, datetime.datetime): 78 | obj[k] = dt_util.as_local(value) 79 | return obj 80 | 81 | return validate 82 | 83 | 84 | CONFIG_SCHEMA_INTEGRATION = { 85 | vol.Optional(CONF_ENABLE_UPDATE, default=False): cv.boolean, 86 | vol.Optional(CONF_BASIC_CALENDAR, default=False): cv.boolean, 87 | vol.Optional(CONF_GROUPS, default=False): cv.boolean, 88 | vol.Optional(CONF_SHARED_MAILBOX, default=""): cv.string, 89 | } 90 | 91 | CALENDAR_SERVICE_RESPOND_SCHEMA = { 92 | vol.Required(ATTR_EVENT_ID): cv.string, 93 | vol.Required(ATTR_RESPONSE, None): cv.enum(EventResponse), 94 | vol.Optional(ATTR_SEND_RESPONSE, True): bool, 95 | vol.Optional(ATTR_MESSAGE, None): cv.string, 96 | } 97 | 98 | CALENDAR_SERVICE_ATTENDEE_SCHEMA = vol.Schema( 99 | { 100 | vol.Required(ATTR_EMAIL): cv.string, 101 | vol.Required(ATTR_TYPE): cv.enum(AttendeeType), 102 | } 103 | ) 104 | 105 | CALENDAR_SERVICE_CREATE_SCHEMA = vol.All( 106 | cv.make_entity_service_schema( 107 | { 108 | vol.Required(ATTR_SUBJECT): cv.string, 109 | vol.Required(ATTR_START): cv.datetime, 110 | vol.Required(ATTR_END): cv.datetime, 111 | vol.Optional(ATTR_BODY): cv.string, 112 | vol.Optional(ATTR_LOCATION): cv.string, 113 | vol.Optional(ATTR_CATEGORIES): [cv.string], 114 | vol.Optional(ATTR_SENSITIVITY): vol.Coerce(EventSensitivity), 115 | vol.Optional(ATTR_SHOW_AS): vol.Coerce(EventShowAs), 116 | vol.Optional(ATTR_IS_ALL_DAY): bool, 117 | vol.Optional(ATTR_ATTENDEES): [CALENDAR_SERVICE_ATTENDEE_SCHEMA], 118 | } 119 | ), 120 | _has_consistent_timezone(ATTR_START, ATTR_END), 121 | _as_local_timezone(ATTR_START, ATTR_END), 122 | ) 123 | 124 | CALENDAR_SERVICE_MODIFY_SCHEMA = vol.All( 125 | cv.make_entity_service_schema( 126 | { 127 | vol.Required(ATTR_EVENT_ID): cv.string, 128 | vol.Optional(ATTR_START): cv.datetime, 129 | vol.Optional(ATTR_END): cv.datetime, 130 | vol.Optional(ATTR_SUBJECT): cv.string, 131 | vol.Optional(ATTR_BODY): cv.string, 132 | vol.Optional(ATTR_LOCATION): cv.string, 133 | vol.Optional(ATTR_CATEGORIES): [cv.string], 134 | vol.Optional(ATTR_SENSITIVITY): vol.Coerce(EventSensitivity), 135 | vol.Optional(ATTR_SHOW_AS): vol.Coerce(EventShowAs), 136 | vol.Optional(ATTR_IS_ALL_DAY): bool, 137 | vol.Optional(ATTR_ATTENDEES): [CALENDAR_SERVICE_ATTENDEE_SCHEMA], 138 | } 139 | ), 140 | _has_consistent_timezone(ATTR_START, ATTR_END), 141 | _as_local_timezone(ATTR_START, ATTR_END), 142 | ) 143 | 144 | 145 | CALENDAR_SERVICE_REMOVE_SCHEMA = { 146 | vol.Required(ATTR_EVENT_ID): cv.string, 147 | } 148 | 149 | YAML_CALENDAR_ENTITY_SCHEMA = vol.Schema( 150 | { 151 | vol.Required(CONF_NAME): cv.string, 152 | vol.Required(CONF_DEVICE_ID): cv.string, 153 | vol.Required(CONF_HOURS_FORWARD_TO_GET, default=24): int, 154 | vol.Required(CONF_HOURS_BACKWARD_TO_GET, default=0): int, 155 | vol.Required(CONF_TRACK): cv.boolean, 156 | vol.Optional(CONF_SEARCH): cv.string, 157 | vol.Optional(CONF_EXCLUDE): [cv.string], 158 | vol.Optional(CONF_MAX_RESULTS): cv.positive_int, 159 | vol.Optional(CONF_SENSITIVITY_EXCLUDE): vol.All( 160 | cv.ensure_list, [vol.Coerce(EventSensitivity)] 161 | ), 162 | } 163 | ) 164 | 165 | YAML_CALENDAR_DEVICE_SCHEMA = vol.Schema( 166 | { 167 | vol.Required(CONF_CAL_ID): cv.string, 168 | vol.Required(CONF_ENTITIES, None): vol.All( 169 | cv.ensure_list, [YAML_CALENDAR_ENTITY_SCHEMA] 170 | ), 171 | }, 172 | extra=vol.ALLOW_EXTRA, 173 | ) 174 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=protected-access,redefined-outer-name, unused-argument, line-too-long, unused-import 2 | """Global fixtures for integration.""" 3 | 4 | import json 5 | import sys 6 | from copy import deepcopy 7 | from unittest.mock import patch 8 | 9 | import pytest 10 | from homeassistant.core import HomeAssistant 11 | from requests_mock import Mocker 12 | 13 | from .const import ENTITY_NAME, LEGACY_TOKEN, TOKEN_LOCATION 14 | from .helpers.mock_config_entry import MS365MockConfigEntry 15 | from .helpers.utils import build_token_file 16 | from .integration import api 17 | from .integration.const_integration import ( 18 | BASE_CONFIG_ENTRY, 19 | BASE_TOKEN_PERMS, 20 | DOMAIN, 21 | UPDATE_OPTIONS, 22 | UPDATE_TOKEN_PERMS, 23 | MS365ConfigFlow, 24 | ) 25 | from .integration.helpers_integration.mocks import MS365MOCKS 26 | 27 | pytest_plugins = [ 28 | "pytest_homeassistant_custom_component", 29 | "tests.integration.fixtures", 30 | ] # pylint: disable=invalid-name 31 | THIS_MODULE = sys.modules[__name__] 32 | 33 | 34 | @pytest.fixture(autouse=True) 35 | def folder_setup(tmp_path): 36 | """Setup the testing session.""" 37 | directory = tmp_path / TOKEN_LOCATION 38 | directory.mkdir(parents=True, exist_ok=True) 39 | 40 | 41 | @pytest.fixture(autouse=True) 42 | def token_storage_path_setup(tmp_path): 43 | """Setup the storage paths.""" 44 | tk_path = tmp_path / TOKEN_LOCATION 45 | 46 | with patch.object( 47 | api, 48 | "build_config_file_path", 49 | return_value=tk_path, 50 | ): 51 | yield 52 | 53 | 54 | # This fixture enables loading custom integrations in all tests. 55 | # Remove to enable selective use of this fixture 56 | @pytest.fixture(autouse=True) 57 | def auto_enable_custom_integrations(enable_custom_integrations): # pylint: disable=unused-argument 58 | """Automatically enable loading custom integrations in all tests.""" 59 | return 60 | 61 | 62 | @pytest.fixture(autouse=True) 63 | async def request_setup(current_request_with_host: None) -> None: # pylint: disable=unused-argument 64 | """Request setup.""" 65 | 66 | 67 | # This fixture is used to prevent HomeAssistant from attempting to create and dismiss persistent 68 | # notifications. These calls would fail without this fixture since the persistent_notification 69 | # integration is never loaded during a test. 70 | @pytest.fixture(name="skip_notifications", autouse=True) 71 | def skip_notifications_fixture(): 72 | """Skip notification calls.""" 73 | with patch("homeassistant.components.persistent_notification.async_create"), patch( 74 | "homeassistant.components.persistent_notification.async_dismiss" 75 | ): 76 | yield 77 | 78 | 79 | @pytest.fixture 80 | def base_config_entry(request, hass: HomeAssistant) -> MS365MockConfigEntry: 81 | """Create MS365 entry in Home Assistant.""" 82 | data = deepcopy(BASE_CONFIG_ENTRY) 83 | options = None 84 | if hasattr(request, "param"): 85 | for key, value in request.param.items(): 86 | if key == "options": 87 | options = value 88 | else: 89 | data[key] = value 90 | entry = MS365MockConfigEntry( 91 | domain=DOMAIN, 92 | title=ENTITY_NAME, 93 | unique_id=DOMAIN, 94 | data=data, 95 | options=options, 96 | version=MS365ConfigFlow.VERSION, 97 | minor_version=MS365ConfigFlow.MINOR_VERSION, 98 | ) 99 | entry.runtime_data = None 100 | return entry 101 | 102 | 103 | @pytest.fixture 104 | def v1_config_entry(request, hass: HomeAssistant) -> MS365MockConfigEntry: 105 | """Create MS365 entry in Home Assistant.""" 106 | data = deepcopy(BASE_CONFIG_ENTRY) 107 | options = None 108 | if hasattr(request, "param"): 109 | for key, value in request.param.items(): 110 | if key == "options": 111 | options = value 112 | else: 113 | data[key] = value 114 | entry = MS365MockConfigEntry( 115 | domain=DOMAIN, 116 | title=ENTITY_NAME, 117 | unique_id=DOMAIN, 118 | data=data, 119 | options=options, 120 | version=1, 121 | minor_version=1, 122 | ) 123 | entry.runtime_data = None 124 | return entry 125 | 126 | 127 | @pytest.fixture 128 | def base_token(request, tmp_path): 129 | """Setup a basic token.""" 130 | perms = BASE_TOKEN_PERMS 131 | if hasattr(request, "param"): 132 | perms = request.param 133 | build_token_file(tmp_path, perms) 134 | 135 | 136 | @pytest.fixture 137 | def legacy_token(tmp_path): 138 | """Setup a legacy token.""" 139 | token = LEGACY_TOKEN 140 | filename = tmp_path / TOKEN_LOCATION / f"{DOMAIN}_{ENTITY_NAME}.token" 141 | with open(filename, "w", encoding="UTF8") as f: 142 | json.dump(token, f, ensure_ascii=False, indent=1) 143 | 144 | 145 | @pytest.fixture 146 | async def setup_base_integration( 147 | request, 148 | hass: HomeAssistant, 149 | requests_mock: Mocker, 150 | base_token, 151 | base_config_entry: MS365MockConfigEntry, 152 | ) -> None: 153 | """Fixture for setting up the component.""" 154 | method_name = "standard_mocks" 155 | if hasattr(request, "param") and "method_name" in request.param: 156 | method_name = request.param["method_name"] 157 | 158 | mock_method = getattr(MS365MOCKS, method_name) 159 | mock_method(requests_mock) 160 | 161 | base_config_entry.add_to_hass(hass) 162 | await hass.config_entries.async_setup(base_config_entry.entry_id) 163 | await hass.async_block_till_done() 164 | 165 | 166 | @pytest.fixture 167 | async def setup_update_integration( 168 | tmp_path, 169 | hass: HomeAssistant, 170 | requests_mock: Mocker, 171 | base_config_entry: MS365MockConfigEntry, 172 | ) -> None: 173 | """Fixture for setting up the component.""" 174 | build_token_file(tmp_path, UPDATE_TOKEN_PERMS) 175 | MS365MOCKS.standard_mocks(requests_mock) 176 | base_config_entry.add_to_hass(hass) 177 | data = deepcopy(BASE_CONFIG_ENTRY) 178 | for key, value in UPDATE_OPTIONS.items(): 179 | data[key] = value 180 | hass.config_entries.async_update_entry(base_config_entry, data=data) 181 | 182 | await hass.config_entries.async_setup(base_config_entry.entry_id) 183 | await hass.async_block_till_done() 184 | -------------------------------------------------------------------------------- /tests/helpers/utils.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=line-too-long 2 | """Utilities for MS365 testing.""" 3 | 4 | import json 5 | import re 6 | import shutil 7 | import time 8 | from datetime import datetime, timezone 9 | from pathlib import Path 10 | 11 | from ..const import ( 12 | CLIENT_ID, 13 | ENTITY_NAME, 14 | TEST_DATA_INTEGRATION_LOCATION, 15 | TEST_DATA_LOCATION, 16 | TOKEN_LOCATION, 17 | TOKEN_PARAMS, 18 | ) 19 | from ..integration.const_integration import DOMAIN, URL 20 | 21 | TOKEN_TIME = 5000 22 | 23 | 24 | def mock_token(requests_mock, scope): 25 | """Mock up the token response based on scope.""" 26 | token = json.dumps(build_retrieved_token(scope)) 27 | requests_mock.post( 28 | "https://login.microsoftonline.com/common/oauth2/v2.0/token", 29 | text=token, 30 | ) 31 | mock_call(requests_mock, URL.OPENID, "openid") 32 | 33 | 34 | def _build_file_token(scope): 35 | """Build a token""" 36 | perms = f"{scope} User.Read email openid profile" 37 | expire = int(time.time() + TOKEN_TIME) 38 | return { 39 | "AccessToken": { 40 | f"fake-user-id.fake-home-id-login.microsoftonline.com-accesstoken-{CLIENT_ID}-common-{perms.lower()}": { 41 | "credential_type": "AccessToken", 42 | "secret": "fakeaccesstoken", 43 | "home_account_id": "fake-user-id.fake-home-id", 44 | "environment": "login.microsoftonline.com", 45 | "client_id": CLIENT_ID, 46 | "target": perms, 47 | "realm": "common", 48 | "token_type": "Bearer", 49 | "cached_at": f"{int(time.time())}", 50 | "expires_on": f"{expire}", 51 | "extended_expires_on": f"{expire}", 52 | } 53 | }, 54 | "Account": { 55 | "fake-user-id.fake-home-id-login.microsoftonline.com-common": { 56 | "home_account_id": "fake-user-id.fake-home-id", 57 | "environment": "login.microsoftonline.com", 58 | "realm": "common", 59 | "local_account_id": "fake-user-id", 60 | "username": "john@nomail.com", 61 | "authority_type": "MSSTS", 62 | "account_source": "authorization_code", 63 | } 64 | }, 65 | "IdToken": { 66 | f"fake-user-id.fake-home-id-login.microsoftonline.com-idtoken-{CLIENT_ID}-common-": { 67 | "credential_type": "IdToken", 68 | "secret": "fakeidtoken", 69 | "home_account_id": "fake-user-id.fake-home-id", 70 | "environment": "login.microsoftonline.com", 71 | "realm": "common", 72 | "client_id": CLIENT_ID, 73 | } 74 | }, 75 | "RefreshToken": { 76 | f"fake-user-id.fake-home-id-login.microsoftonline.com-refreshtoken-{CLIENT_ID}--{perms.lower()}": { 77 | "credential_type": "RefreshToken", 78 | "secret": "1.farkerh.fakerefreshtoken", 79 | "home_account_id": "fake-user-id.fake-home-id", 80 | "environment": "login.microsoftonline.com", 81 | "client_id": CLIENT_ID, 82 | "target": perms, 83 | "last_modification_time": f"{int(time.time())}", 84 | } 85 | }, 86 | "AppMetadata": { 87 | f"appmetadata-login.microsoftonline.com-{CLIENT_ID}": { 88 | "client_id": CLIENT_ID, 89 | "environment": "login.microsoftonline.com", 90 | } 91 | }, 92 | } 93 | 94 | 95 | def build_retrieved_token(scope): 96 | """Build a token""" 97 | return { 98 | "token_type": "Bearer", 99 | "scope": f"{scope} User.Read profile openid email", 100 | "expires_in": TOKEN_TIME, 101 | "ext_expires_in": TOKEN_TIME, 102 | "access_token": "fakeaccesstoken", 103 | "refresh_token": "fakerefreshtoken", 104 | } 105 | 106 | 107 | def build_token_url(result, token_url): 108 | """Build the correct token url""" 109 | state = re.search("state=(.*?)&", result["description_placeholders"]["auth_url"])[1] 110 | 111 | return f"{token_url}?{TOKEN_PARAMS.format(state)}" 112 | 113 | 114 | def build_token_file(tmp_path, scope): 115 | """Build a token file.""" 116 | token = _build_file_token(scope) 117 | filename = tmp_path / TOKEN_LOCATION / f"{DOMAIN}_{ENTITY_NAME}.token" 118 | with open(filename, "w", encoding="UTF8") as f: 119 | json.dump(token, f, ensure_ascii=False, indent=1) 120 | 121 | 122 | def mock_call( 123 | requests_mock, 124 | urlname, 125 | datafile, 126 | unique=None, 127 | start=None, 128 | end=None, 129 | method="get", 130 | ): 131 | """Mock a call""" 132 | data = load_json(f"O365/{datafile}.json") 133 | if start: 134 | data = data.replace("2020-01-01", start).replace("2020-01-02", end) 135 | url = urlname.value 136 | if unique: 137 | url = f"{url}/{unique}" 138 | if method == "get": 139 | requests_mock.get( 140 | url, 141 | text=data, 142 | ) 143 | elif method == "post": 144 | requests_mock.post( 145 | url, 146 | text=data, 147 | ) 148 | 149 | 150 | def load_json(filename): 151 | """Load a json file as string.""" 152 | filepath = TEST_DATA_INTEGRATION_LOCATION / filename 153 | return Path(filepath).read_text(encoding="utf8") 154 | 155 | 156 | def check_entity_state( 157 | hass, 158 | entity_name, 159 | entity_state, 160 | entity_attributes=None, 161 | data_length=None, 162 | attributes=None, 163 | ): 164 | """Check entity state.""" 165 | state = hass.states.get(entity_name) 166 | print("*************************** State") 167 | print(state) 168 | print("--- State Attributes") 169 | print(state.attributes) 170 | assert state.state == entity_state 171 | if entity_attributes: 172 | 173 | if "data" in state.attributes: 174 | assert state.attributes["data"] == entity_attributes 175 | else: 176 | assert state.attributes == entity_attributes 177 | if data_length is not None: 178 | assert len(state.attributes["data"]) == data_length 179 | 180 | if attributes is not None: 181 | for key, value in attributes.items(): 182 | assert state.attributes.get(key, None) == value 183 | 184 | 185 | def utcnow(): 186 | """Get UTC Now.""" 187 | return datetime.now(timezone.utc) 188 | 189 | 190 | def token_setup(tmp_path, infile): 191 | """Setup a token file""" 192 | fromfile = TEST_DATA_LOCATION / f"token/{infile}.token" 193 | tofile = tmp_path / TOKEN_LOCATION / f"{DOMAIN}_test.token" 194 | shutil.copy(fromfile, tofile) 195 | 196 | 197 | def get_schema_default(schema, key_name): 198 | """Iterate schema to find a key.""" 199 | for schema_key in schema: 200 | if schema_key == key_name: 201 | try: 202 | return schema_key.default() 203 | except TypeError: 204 | return None 205 | raise KeyError(f"{key_name} not found in schema") 206 | -------------------------------------------------------------------------------- /custom_components/ms365_calendar/integration/coordinator_integration.py: -------------------------------------------------------------------------------- 1 | """Calendar coordinator processing.""" 2 | 3 | import logging 4 | from collections.abc import Iterable 5 | from datetime import datetime, timedelta, timezone 6 | 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.const import STATE_OK, STATE_PROBLEM, STATE_UNKNOWN 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.exceptions import HomeAssistantError 11 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 12 | from homeassistant.util import dt as dt_util 13 | from O365.calendar import Event # pylint: disable=no-name-in-module) 14 | from requests.exceptions import ConnectionError as RequestConnectionError 15 | from requests.exceptions import HTTPError, RetryError 16 | 17 | from .const_integration import ( 18 | CONF_ADVANCED_OPTIONS, 19 | CONF_DAYS_BACKWARD, 20 | CONF_DAYS_FORWARD, 21 | CONF_HOURS_BACKWARD_TO_GET, 22 | CONF_HOURS_FORWARD_TO_GET, 23 | CONF_UPDATE_INTERVAL, 24 | DEFAULT_DAYS_BACKWARD, 25 | DEFAULT_DAYS_FORWARD, 26 | DEFAULT_UPDATE_INTERVAL, 27 | ) 28 | from .sync.sync import MS365CalendarEventSyncManager 29 | from .sync.timeline import MS365Timeline 30 | from .utils_integration import get_end_date, get_start_date 31 | 32 | _LOGGER = logging.getLogger(__name__) 33 | # Maximum number of upcoming events to consider for state changes between 34 | # coordinator updates. 35 | # MAX_UPCOMING_EVENTS = 20 36 | 37 | 38 | class MS365CalendarSyncCoordinator(DataUpdateCoordinator): 39 | """Coordinator for calendar RPC calls that use an efficient sync.""" 40 | 41 | config_entry: ConfigEntry 42 | 43 | def __init__( 44 | self, 45 | hass: HomeAssistant, 46 | entry: ConfigEntry, 47 | sync: MS365CalendarEventSyncManager, 48 | name: str, 49 | entity, 50 | ) -> None: 51 | """Create the CalendarSyncUpdateCoordinator.""" 52 | update_interval = entry.options.get(CONF_ADVANCED_OPTIONS, {}).get( 53 | CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL 54 | ) 55 | super().__init__( 56 | hass, 57 | _LOGGER, 58 | config_entry=entry, 59 | name=name, 60 | update_interval=timedelta(seconds=update_interval), 61 | ) 62 | days_backward = entry.options.get(CONF_ADVANCED_OPTIONS, {}).get( 63 | CONF_DAYS_BACKWARD, DEFAULT_DAYS_BACKWARD 64 | ) 65 | days_forward = entry.options.get(CONF_ADVANCED_OPTIONS, {}).get( 66 | CONF_DAYS_FORWARD, DEFAULT_DAYS_FORWARD 67 | ) 68 | self.sync = sync 69 | # self._upcoming_timeline: MS365Timeline | None = None 70 | self.event = None 71 | self._sync_event_min_time = timedelta( 72 | days=(min(entity.get(CONF_HOURS_BACKWARD_TO_GET) / 24, days_backward)) 73 | ) 74 | self._sync_event_max_time = timedelta( 75 | days=(max(entity.get(CONF_HOURS_FORWARD_TO_GET) / 24, days_forward)) 76 | ) 77 | self._last_sync_min = None 78 | self._last_sync_max = None 79 | self.entity = entity 80 | self._error = False 81 | self.sync_state = STATE_UNKNOWN 82 | 83 | async def _async_update_data(self) -> MS365Timeline: 84 | """Fetch data from API endpoint.""" 85 | _LOGGER.debug("Started fetching %s data", self.name) 86 | 87 | self._last_sync_min = dt_util.now() + self._sync_event_min_time 88 | self._last_sync_max = dt_util.now() + self._sync_event_max_time 89 | try: 90 | await self.sync.run(self._last_sync_min, self._last_sync_max) 91 | self.sync_state = STATE_OK 92 | except (HTTPError, RetryError, RequestConnectionError) as err: 93 | _LOGGER.error( 94 | "Error syncing calendar events from MS Graph, fetching from cache: %s", 95 | err, 96 | ) 97 | self.sync_state = STATE_PROBLEM 98 | 99 | return await self.sync.store_service.async_get_timeline( 100 | dt_util.get_default_time_zone() 101 | ) 102 | 103 | # self._upcoming_timeline = timeline 104 | # return timeline 105 | 106 | async def async_get_events( 107 | self, start_date: datetime, end_date: datetime 108 | ) -> Iterable[Event]: 109 | """Get all events in a specific time frame.""" 110 | if not self.data: 111 | raise HomeAssistantError( 112 | "Unable to get events: Sync from server has not completed" 113 | ) 114 | 115 | # If the request is for outside of the synced data, manually request it now, 116 | # will not cache it though 117 | if start_date < self._last_sync_min or end_date > self._last_sync_max: 118 | _LOGGER.debug( 119 | "Fetch events from api - %s - %s - %s", self.name, start_date, end_date 120 | ) 121 | try: 122 | return await self.sync.async_list_events(start_date, end_date) 123 | except (HTTPError, RetryError, RequestConnectionError) as err: 124 | self._log_error( 125 | "Error getting calendar event range " 126 | + "from MS Graph, fetching from cache.", 127 | err, 128 | ) 129 | _LOGGER.debug( 130 | "Fetch events from cache - %s - %s - %s", 131 | self.name, 132 | start_date, 133 | end_date, 134 | ) 135 | 136 | return self.data.overlapping( 137 | start_date, 138 | end_date, 139 | ) 140 | 141 | def get_current_event(self): 142 | """Get the current event.""" 143 | if not self.data: 144 | _LOGGER.debug( 145 | "No current event found for %s", 146 | self.sync.calendar_id, 147 | ) 148 | self.event = None 149 | return None 150 | 151 | today = datetime.now(timezone.utc) 152 | events = self.data.overlapping( 153 | today, 154 | today + timedelta(days=1), 155 | ) 156 | 157 | started_event = None 158 | not_started_event = None 159 | all_day_event = None 160 | for event in events: 161 | if event.is_all_day: 162 | if not all_day_event and not self.is_finished(event): 163 | all_day_event = event 164 | continue 165 | if self.is_started(event) and not self.is_finished(event): 166 | if not started_event: 167 | started_event = event 168 | continue 169 | if ( 170 | not self.is_finished(event) 171 | and not event.is_all_day 172 | and not not_started_event 173 | ): 174 | not_started_event = event 175 | 176 | vevent = None 177 | if started_event: 178 | vevent = started_event 179 | elif all_day_event: 180 | vevent = all_day_event 181 | elif not_started_event: 182 | vevent = not_started_event 183 | 184 | return vevent 185 | 186 | @staticmethod 187 | def is_started(vevent): 188 | """Is it over.""" 189 | return dt_util.utcnow() >= MS365CalendarSyncCoordinator.to_datetime( 190 | get_start_date(vevent) 191 | ) 192 | 193 | @staticmethod 194 | def is_finished(vevent): 195 | """Is it over.""" 196 | return dt_util.utcnow() >= MS365CalendarSyncCoordinator.to_datetime( 197 | get_end_date(vevent) 198 | ) 199 | 200 | @staticmethod 201 | def to_datetime(obj): 202 | """To datetime.""" 203 | if not isinstance(obj, datetime): 204 | date_obj = dt_util.start_of_local_day( 205 | dt_util.dt.datetime.combine(obj, dt_util.dt.time.min) 206 | ) 207 | else: 208 | date_obj = obj 209 | 210 | return dt_util.as_utc(date_obj) 211 | 212 | def _log_error(self, error, err): 213 | if not self._error: 214 | _LOGGER.warning("%s - %s", error, err) 215 | self._error = True 216 | else: 217 | _LOGGER.debug("Repeat error - %s - %s", error, err) 218 | --------------------------------------------------------------------------------