diff --git a/.coveragerc b/.coveragerc index e256be60466d36fd69205f43c25ef36f0d917a3f..bf6c81bf51471e8f60db6ffadc811b836b8872fd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -262,6 +262,9 @@ omit = homeassistant/components/fibaro/* homeassistant/components/filesize/sensor.py homeassistant/components/fints/sensor.py + homeassistant/components/fireservicerota/__init__.py + homeassistant/components/fireservicerota/const.py + homeassistant/components/fireservicerota/sensor.py homeassistant/components/firmata/__init__.py homeassistant/components/firmata/binary_sensor.py homeassistant/components/firmata/board.py diff --git a/CODEOWNERS b/CODEOWNERS index 1c63bd37c456b11d0fa77cdb58f417cb515df4a5..23eacec8f22461900ec0f5ceb580eca467ac68c4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -146,6 +146,7 @@ homeassistant/components/ezviz/* @baqs homeassistant/components/fastdotcom/* @rohankapoorcom homeassistant/components/file/* @fabaff homeassistant/components/filter/* @dgomes +homeassistant/components/fireservicerota/* @cyberjunky homeassistant/components/firmata/* @DaAwesomeP homeassistant/components/fixer/* @fabaff homeassistant/components/flick_electric/* @ZephireNZ diff --git a/homeassistant/components/fireservicerota/__init__.py b/homeassistant/components/fireservicerota/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a0fc1d68d230309f153f66aee1e0c04cd01a3856 --- /dev/null +++ b/homeassistant/components/fireservicerota/__init__.py @@ -0,0 +1,246 @@ +"""The FireServiceRota integration.""" +import asyncio +from datetime import timedelta +import logging + +from pyfireservicerota import ( + ExpiredTokenError, + FireServiceRota, + FireServiceRotaIncidents, + InvalidAuthError, + InvalidTokenError, +) + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, WSS_BWRURL + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + +SUPPORTED_PLATFORMS = {SENSOR_DOMAIN} + + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + """Set up the FireServiceRota component.""" + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up FireServiceRota from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + coordinator = FireServiceRotaCoordinator(hass, entry) + await coordinator.setup() + await coordinator.async_availability_update() + + if coordinator.token_refresh_failure: + return False + + hass.data[DOMAIN][entry.entry_id] = coordinator + + for platform in SUPPORTED_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload FireServiceRota config entry.""" + + hass.data[DOMAIN][entry.entry_id].websocket.stop_listener() + + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in SUPPORTED_PLATFORMS + ] + ) + ) + + if unload_ok: + del hass.data[DOMAIN][entry.entry_id] + + return unload_ok + + +class FireServiceRotaOauth: + """Handle authentication tokens.""" + + def __init__(self, hass, entry, fsr): + """Initialize the oauth object.""" + self._hass = hass + self._entry = entry + + self._url = entry.data[CONF_URL] + self._username = entry.data[CONF_USERNAME] + self._fsr = fsr + + async def async_refresh_tokens(self) -> bool: + """Refresh tokens and update config entry.""" + _LOGGER.debug("Refreshing authentication tokens after expiration") + + try: + token_info = await self._hass.async_add_executor_job( + self._fsr.refresh_tokens + ) + + except (InvalidAuthError, InvalidTokenError): + _LOGGER.error("Error refreshing tokens, triggered reauth workflow") + self._hass.add_job( + self._hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data={ + **self._entry.data, + }, + ) + ) + + return False + + _LOGGER.debug("Saving new tokens in config entry") + self._hass.config_entries.async_update_entry( + self._entry, + data={ + "auth_implementation": DOMAIN, + CONF_URL: self._url, + CONF_USERNAME: self._username, + CONF_TOKEN: token_info, + }, + ) + + return True + + +class FireServiceRotaWebSocket: + """Define a FireServiceRota websocket manager object.""" + + def __init__(self, hass, entry): + """Initialize the websocket object.""" + self._hass = hass + self._entry = entry + + self._fsr_incidents = FireServiceRotaIncidents(on_incident=self._on_incident) + self._incident_data = None + + def _construct_url(self) -> str: + """Return URL with latest access token.""" + return WSS_BWRURL.format( + self._entry.data[CONF_URL], self._entry.data[CONF_TOKEN]["access_token"] + ) + + def incident_data(self) -> object: + """Return incident data.""" + return self._incident_data + + def _on_incident(self, data) -> None: + """Received new incident, update data.""" + _LOGGER.debug("Received new incident via websocket: %s", data) + self._incident_data = data + dispatcher_send(self._hass, f"{DOMAIN}_{self._entry.entry_id}_update") + + def start_listener(self) -> None: + """Start the websocket listener.""" + _LOGGER.debug("Starting incidents listener") + self._fsr_incidents.start(self._construct_url()) + + def stop_listener(self) -> None: + """Stop the websocket listener.""" + _LOGGER.debug("Stopping incidents listener") + self._fsr_incidents.stop() + + +class FireServiceRotaCoordinator(DataUpdateCoordinator): + """Getting the latest data from fireservicerota.""" + + def __init__(self, hass, entry): + """Initialize the data object.""" + self._hass = hass + self._entry = entry + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_method=self.async_availability_update, + update_interval=MIN_TIME_BETWEEN_UPDATES, + ) + + self._url = entry.data[CONF_URL] + self._tokens = entry.data[CONF_TOKEN] + + self.token_refresh_failure = False + self.incident_id = None + + self.fsr = FireServiceRota(base_url=self._url, token_info=self._tokens) + + self.oauth = FireServiceRotaOauth( + self._hass, + self._entry, + self.fsr, + ) + + self.websocket = FireServiceRotaWebSocket(self._hass, self._entry) + + async def setup(self) -> None: + """Set up the coordinator.""" + await self._hass.async_add_executor_job(self.websocket.start_listener) + + async def update_call(self, func, *args): + """Perform update call and return data.""" + if self.token_refresh_failure: + return + + try: + return await self._hass.async_add_executor_job(func, *args) + except (ExpiredTokenError, InvalidTokenError): + self.websocket.stop_listener() + self.token_refresh_failure = True + self.update_interval = None + + if await self.oauth.async_refresh_tokens(): + self.update_interval = MIN_TIME_BETWEEN_UPDATES + self.token_refresh_failure = False + self.websocket.start_listener() + + return await self._hass.async_add_executor_job(func, *args) + + async def async_availability_update(self) -> None: + """Get the latest availability data.""" + _LOGGER.debug("Updating availability data") + + return await self.update_call( + self.fsr.get_availability, str(self._hass.config.time_zone) + ) + + async def async_response_update(self) -> object: + """Get the latest incident response data.""" + data = self.websocket.incident_data() + if data is None or "id" not in data: + return + + self.incident_id = data("id") + _LOGGER.debug("Updating incident response data for id: %s", self.incident_id) + + return await self.update_call(self.fsr.get_incident_response, self.incident_id) + + async def async_set_response(self, value) -> None: + """Set incident response status.""" + _LOGGER.debug( + "Setting incident response for incident '%s' to status '%s'", + self.incident_id, + value, + ) + + await self.update_call(self.fsr.set_incident_response, self.incident_id, value) diff --git a/homeassistant/components/fireservicerota/config_flow.py b/homeassistant/components/fireservicerota/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..e5c49fda6ad06a3e3c4e5bc403d270f497c0f069 --- /dev/null +++ b/homeassistant/components/fireservicerota/config_flow.py @@ -0,0 +1,129 @@ +"""Config flow for FireServiceRota.""" +from pyfireservicerota import FireServiceRota, InvalidAuthError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME + +from .const import DOMAIN, URL_LIST # pylint: disable=unused-import + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL, default="www.brandweerrooster.nl"): vol.In(URL_LIST), + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class FireServiceRotaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a FireServiceRota config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize config flow.""" + self.api = None + self._base_url = None + self._username = None + self._password = None + self._existing_entry = None + self._description_placeholders = None + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is None: + return self._show_setup_form(user_input, errors) + + return await self._validate_and_create_entry(user_input, "user") + + async def _validate_and_create_entry(self, user_input, step_id): + """Check if config is valid and create entry if so.""" + self._password = user_input[CONF_PASSWORD] + + extra_inputs = user_input + + if self._existing_entry: + extra_inputs = self._existing_entry + + self._username = extra_inputs[CONF_USERNAME] + self._base_url = extra_inputs[CONF_URL] + + if self.unique_id is None: + await self.async_set_unique_id(self._username) + self._abort_if_unique_id_configured() + + try: + self.api = FireServiceRota( + base_url=self._base_url, + username=self._username, + password=self._password, + ) + token_info = await self.hass.async_add_executor_job(self.api.request_tokens) + + except InvalidAuthError: + self.api = None + return self.async_show_form( + step_id=step_id, + data_schema=DATA_SCHEMA, + errors={"base": "invalid_auth"}, + ) + + data = { + "auth_implementation": DOMAIN, + CONF_URL: self._base_url, + CONF_USERNAME: self._username, + CONF_TOKEN: token_info, + } + + if step_id == "user": + return self.async_create_entry(title=self._username, data=data) + + for entry in self.hass.config_entries.async_entries(DOMAIN): + if entry.unique_id == self.unique_id: + self.hass.config_entries.async_update_entry(entry, data=data) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + + def _show_setup_form(self, user_input=None, errors=None, step_id="user"): + """Show the setup form to the user.""" + + if user_input is None: + user_input = {} + + if step_id == "user": + schema = { + vol.Required(CONF_URL, default="www.brandweerrooster.nl"): vol.In( + URL_LIST + ), + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + else: + schema = {vol.Required(CONF_PASSWORD): str} + + return self.async_show_form( + step_id=step_id, + data_schema=vol.Schema(schema), + errors=errors or {}, + description_placeholders=self._description_placeholders, + ) + + async def async_step_reauth(self, user_input=None): + """Get new tokens for a config entry that can't authenticate.""" + + if not self._existing_entry: + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._existing_entry = user_input.copy() + self._description_placeholders = {"username": user_input[CONF_USERNAME]} + user_input = None + + if user_input is None: + return self._show_setup_form(step_id=config_entries.SOURCE_REAUTH) + + return await self._validate_and_create_entry( + user_input, config_entries.SOURCE_REAUTH + ) diff --git a/homeassistant/components/fireservicerota/const.py b/homeassistant/components/fireservicerota/const.py new file mode 100644 index 0000000000000000000000000000000000000000..5ca0b7d7e64231bb62bfde18e6e67911f617c71f --- /dev/null +++ b/homeassistant/components/fireservicerota/const.py @@ -0,0 +1,9 @@ +"""Constants for the FireServiceRota integration.""" + +DOMAIN = "fireservicerota" + +URL_LIST = { + "www.brandweerrooster.nl": "BrandweerRooster", + "www.fireservicerota.co.uk": "FireServiceRota", +} +WSS_BWRURL = "wss://{0}/cable?access_token={1}" diff --git a/homeassistant/components/fireservicerota/manifest.json b/homeassistant/components/fireservicerota/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..6485d155f50e433987e139deb10f6256898c7a0b --- /dev/null +++ b/homeassistant/components/fireservicerota/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "fireservicerota", + "name": "FireServiceRota", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/fireservicerota", + "requirements": ["pyfireservicerota==0.0.40"], + "codeowners": ["@cyberjunky"] +} diff --git a/homeassistant/components/fireservicerota/sensor.py b/homeassistant/components/fireservicerota/sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..4360a834288c6868bcd51a56eabafa21ae8f0d00 --- /dev/null +++ b/homeassistant/components/fireservicerota/sensor.py @@ -0,0 +1,128 @@ +"""Sensor platform for FireServiceRota integration.""" +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import HomeAssistantType + +from .const import DOMAIN as FIRESERVICEROTA_DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up FireServiceRota sensor based on a config entry.""" + coordinator = hass.data[FIRESERVICEROTA_DOMAIN][entry.entry_id] + + async_add_entities([IncidentsSensor(coordinator)]) + + +class IncidentsSensor(RestoreEntity): + """Representation of FireServiceRota incidents sensor.""" + + def __init__(self, coordinator): + """Initialize.""" + self._coordinator = coordinator + self._entry_id = self._coordinator._entry.entry_id + self._unique_id = f"{self._coordinator._entry.unique_id}_Incidents" + self._state = None + self._state_attributes = {} + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return "Incidents" + + @property + def icon(self) -> str: + """Return the icon to use in the frontend.""" + if ( + "prio" in self._state_attributes + and self._state_attributes["prio"][0] == "a" + ): + return "mdi:ambulance" + + return "mdi:fire-truck" + + @property + def state(self) -> str: + """Return the state of the sensor.""" + return self._state + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor.""" + return self._unique_id + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + @property + def device_state_attributes(self) -> object: + """Return available attributes for sensor.""" + attr = {} + data = self._state_attributes + + if not data: + return attr + + for value in ( + "trigger", + "created_at", + "message_to_speech_url", + "prio", + "type", + "responder_mode", + "can_respond_until", + ): + if data.get(value): + attr[value] = data[value] + + if "address" not in data: + continue + + for address_value in ( + "latitude", + "longitude", + "address_type", + "formatted_address", + ): + if address_value in data["address"]: + attr[address_value] = data["address"][address_value] + + return attr + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + if state: + self._state = state.state + self._state_attributes = state.attributes + _LOGGER.debug("Restored entity 'Incidents' state to: %s", self._state) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{FIRESERVICEROTA_DOMAIN}_{self._entry_id}_update", + self.coordinator_update, + ) + ) + + @callback + def coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + data = self._coordinator.websocket.incident_data() + if not data or "body" not in data: + return + + self._state = data["body"] + self._state_attributes = data + self.async_write_ha_state() diff --git a/homeassistant/components/fireservicerota/strings.json b/homeassistant/components/fireservicerota/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..c44673d6c2c3610e171ef753f1203b6a02f0a5cf --- /dev/null +++ b/homeassistant/components/fireservicerota/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]", + "url": "Website" + } + }, + "reauth": { + "description": "Authentication tokens baceame invalid, login to recreate them.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + } +} diff --git a/homeassistant/components/fireservicerota/translations/en.json b/homeassistant/components/fireservicerota/translations/en.json new file mode 100644 index 0000000000000000000000000000000000000000..fabf02b30022a81acf664fe1fb35044f63daf1d4 --- /dev/null +++ b/homeassistant/components/fireservicerota/translations/en.json @@ -0,0 +1,30 @@ +{ + "config": { + "step": { + "user": { + "title": "FireServiceRota", + "data": { + "password": "Password", + "username": "Username", + "url": "Website" + } + }, + "reauth": { + "description": "Authentication tokens became invalid, login to recreate them.", + "data": { + "password": "Password" + } + } + }, + "error": { + "invalid_auth": "Invalid authentication." + }, + "abort": { + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful" + }, + "create_entry": { + "default": "Successfully authenticated" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 02fdac9ed3e64607b2d311af125863b9d1ec6b34..a0d9cc2dd7962857a37dcd3c3382de116c90e2f9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -58,6 +58,7 @@ FLOWS = [ "enocean", "epson", "esphome", + "fireservicerota", "flick_electric", "flo", "flume", diff --git a/requirements_all.txt b/requirements_all.txt index 024e15f509a00864c37f4b721018eaafbdecc95f..30904d7520e1e1cba2100ead7525e6357ad719c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1381,6 +1381,9 @@ pyeverlights==0.1.0 # homeassistant.components.fido pyfido==2.1.1 +# homeassistant.components.fireservicerota +pyfireservicerota==0.0.40 + # homeassistant.components.flexit pyflexit==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 94b279722e0f4c73fcf2e71d4f3fecb28bb4ff82..862157e45cef928bf73581cb8776da82f4903b1b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -684,6 +684,9 @@ pyeverlights==0.1.0 # homeassistant.components.fido pyfido==2.1.1 +# homeassistant.components.fireservicerota +pyfireservicerota==0.0.40 + # homeassistant.components.flume pyflume==0.5.5 diff --git a/tests/components/fireservicerota/__init__.py b/tests/components/fireservicerota/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..37e5364d782c10376476548a7080e65ad45d9658 --- /dev/null +++ b/tests/components/fireservicerota/__init__.py @@ -0,0 +1 @@ +"""Tests for the FireServiceRota integration.""" diff --git a/tests/components/fireservicerota/test_config_flow.py b/tests/components/fireservicerota/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..b826e6b303bf882b9917b6346bc44153996bf124 --- /dev/null +++ b/tests/components/fireservicerota/test_config_flow.py @@ -0,0 +1,114 @@ +"""Test the FireServiceRota config flow.""" +from pyfireservicerota import InvalidAuthError + +from homeassistant import data_entry_flow +from homeassistant.components.fireservicerota.const import ( # pylint: disable=unused-import + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME + +from tests.async_mock import patch +from tests.common import MockConfigEntry + +MOCK_CONF = { + CONF_USERNAME: "my@email.address", + CONF_PASSWORD: "mypassw0rd", + CONF_URL: "www.brandweerrooster.nl", +} + + +MOCK_DATA = { + "auth_implementation": DOMAIN, + CONF_URL: MOCK_CONF[CONF_URL], + CONF_USERNAME: MOCK_CONF[CONF_USERNAME], + "token": { + "access_token": "test-access-token", + "token_type": "Bearer", + "expires_in": 1234, + "refresh_token": "test-refresh-token", + "created_at": 4321, + }, +} + +MOCK_TOKEN_INFO = { + "access_token": "test-access-token", + "token_type": "Bearer", + "expires_in": 1234, + "refresh_token": "test-refresh-token", + "created_at": 4321, +} + + +async def test_show_form(hass): + """Test that the form is served with no input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_abort_if_already_setup(hass): + """Test abort if already setup.""" + entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONF, unique_id=MOCK_CONF[CONF_USERNAME] + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"}, data=MOCK_CONF + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_invalid_credentials(hass): + """Test that invalid credentials throws an error.""" + + with patch( + "homeassistant.components.fireservicerota.FireServiceRota.request_tokens", + side_effect=InvalidAuthError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"}, data=MOCK_CONF + ) + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_step_user(hass): + """Test the start of the config flow.""" + + with patch( + "homeassistant.components.fireservicerota.config_flow.FireServiceRota" + ) as MockFireServiceRota, patch( + "homeassistant.components.fireservicerota.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.fireservicerota.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + + mock_fireservicerota = MockFireServiceRota.return_value + mock_fireservicerota.request_tokens.return_value = MOCK_TOKEN_INFO + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"}, data=MOCK_CONF + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MOCK_CONF[CONF_USERNAME] + assert result["data"] == { + "auth_implementation": "fireservicerota", + CONF_URL: "www.brandweerrooster.nl", + CONF_USERNAME: "my@email.address", + "token": { + "access_token": "test-access-token", + "token_type": "Bearer", + "expires_in": 1234, + "refresh_token": "test-refresh-token", + "created_at": 4321, + }, + } + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1