From 76f07ec240d7872e65aa968b7120ce1fb999b003 Mon Sep 17 00:00:00 2001 From: Antoni Czaplicki <56671347+Antoni-Czaplicki@users.noreply.github.com> Date: Wed, 30 Mar 2022 17:55:24 +0200 Subject: [PATCH] Add Uonet+ Vulcan integration (#56357) * Add Uonet+ Vulcan integration * Add "configuration_url", fix some minor issues * Refactor and fix tests * Optimize code * Apply suggestion from code review Co-authored-by: Maciej Bieniek * Update error handling * Fix some tests * Update CODEOWNERS and fix pylint * Cleanup vulcan tests * Run prettier Co-authored-by: Maciej Bieniek Co-authored-by: Erik Montnemery --- .coveragerc | 3 + CODEOWNERS | 2 + homeassistant/components/vulcan/__init__.py | 75 ++ homeassistant/components/vulcan/calendar.py | 219 ++++ .../components/vulcan/config_flow.py | 342 +++++++ homeassistant/components/vulcan/const.py | 4 + homeassistant/components/vulcan/fetch_data.py | 97 ++ homeassistant/components/vulcan/manifest.json | 11 + homeassistant/components/vulcan/register.py | 13 + homeassistant/components/vulcan/strings.json | 69 ++ .../components/vulcan/translations/en.json | 69 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/vulcan/__init__.py | 1 + .../fixtures/fake_config_entry_data.json | 16 + .../vulcan/fixtures/fake_student_1.json | 29 + .../vulcan/fixtures/fake_student_2.json | 29 + tests/components/vulcan/test_config_flow.py | 935 ++++++++++++++++++ 19 files changed, 1921 insertions(+) create mode 100644 homeassistant/components/vulcan/__init__.py create mode 100644 homeassistant/components/vulcan/calendar.py create mode 100644 homeassistant/components/vulcan/config_flow.py create mode 100644 homeassistant/components/vulcan/const.py create mode 100644 homeassistant/components/vulcan/fetch_data.py create mode 100644 homeassistant/components/vulcan/manifest.json create mode 100644 homeassistant/components/vulcan/register.py create mode 100644 homeassistant/components/vulcan/strings.json create mode 100644 homeassistant/components/vulcan/translations/en.json create mode 100644 tests/components/vulcan/__init__.py create mode 100644 tests/components/vulcan/fixtures/fake_config_entry_data.json create mode 100644 tests/components/vulcan/fixtures/fake_student_1.json create mode 100644 tests/components/vulcan/fixtures/fake_student_2.json create mode 100644 tests/components/vulcan/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 54502229fe3..d020a59c712 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1379,6 +1379,9 @@ omit = homeassistant/components/volumio/browse_media.py homeassistant/components/volumio/media_player.py homeassistant/components/volvooncall/* + homeassistant/components/vulcan/__init__.py + homeassistant/components/vulcan/calendar.py + homeassistant/components/vulcan/fetch_data.py homeassistant/components/w800rf32/* homeassistant/components/waqi/sensor.py homeassistant/components/waterfurnace/* diff --git a/CODEOWNERS b/CODEOWNERS index 731979781d4..adbfd0205e5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1121,6 +1121,8 @@ build.json @home-assistant/supervisor /homeassistant/components/volumio/ @OnFreund /tests/components/volumio/ @OnFreund /homeassistant/components/volvooncall/ @molobrakos @decompil3d +/homeassistant/components/vulcan/ @Antoni-Czaplicki +/tests/components/vulcan/ @Antoni-Czaplicki /homeassistant/components/wake_on_lan/ @ntilley905 /tests/components/wake_on_lan/ @ntilley905 /homeassistant/components/wallbox/ @hesselonline diff --git a/homeassistant/components/vulcan/__init__.py b/homeassistant/components/vulcan/__init__.py new file mode 100644 index 00000000000..37210778b1c --- /dev/null +++ b/homeassistant/components/vulcan/__init__.py @@ -0,0 +1,75 @@ +"""The Vulcan component.""" +import logging + +from aiohttp import ClientConnectorError +from vulcan import Account, Keystore, Vulcan +from vulcan._utils import VulcanAPIException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["calendar"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Uonet+ Vulcan integration.""" + hass.data.setdefault(DOMAIN, {}) + try: + keystore = Keystore.load(entry.data["keystore"]) + account = Account.load(entry.data["account"]) + client = Vulcan(keystore, account) + await client.select_student() + students = await client.get_students() + for student in students: + if str(student.pupil.id) == str(entry.data["student_id"]): + client.student = student + break + except VulcanAPIException as err: + if str(err) == "The certificate is not authorized.": + _LOGGER.error( + "The certificate is not authorized, please authorize integration again" + ) + raise ConfigEntryAuthFailed from err + _LOGGER.error("Vulcan API error: %s", err) + return False + except ClientConnectorError as err: + if "connection_error" not in hass.data[DOMAIN]: + _LOGGER.error( + "Connection error - please check your internet connection: %s", err + ) + hass.data[DOMAIN]["connection_error"] = True + await client.close() + raise ConfigEntryNotReady from err + hass.data[DOMAIN]["students_number"] = len( + hass.config_entries.async_entries(DOMAIN) + ) + hass.data[DOMAIN][entry.entry_id] = client + + if not entry.update_listeners: + entry.add_update_listener(_async_update_options) + + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + await hass.data[DOMAIN][entry.entry_id].close() + for platform in PLATFORMS: + await hass.config_entries.async_forward_entry_unload(entry, platform) + + return True + + +async def _async_update_options(hass, entry): + """Update options.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/vulcan/calendar.py b/homeassistant/components/vulcan/calendar.py new file mode 100644 index 00000000000..9c853b03197 --- /dev/null +++ b/homeassistant/components/vulcan/calendar.py @@ -0,0 +1,219 @@ +"""Support for Vulcan Calendar platform.""" +import copy +from datetime import date, datetime, timedelta +import logging + +from aiohttp import ClientConnectorError +from vulcan._utils import VulcanAPIException + +from homeassistant.components.calendar import ENTITY_ID_FORMAT, CalendarEventDevice +from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers.template import DATE_STR_FORMAT +from homeassistant.util import Throttle, dt + +from . import DOMAIN +from .const import DEFAULT_SCAN_INTERVAL +from .fetch_data import get_lessons, get_student_info + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the calendar platform for event devices.""" + VulcanCalendarData.MIN_TIME_BETWEEN_UPDATES = timedelta( + minutes=config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + ) + client = hass.data[DOMAIN][config_entry.entry_id] + data = { + "student_info": await get_student_info( + client, config_entry.data.get("student_id") + ), + "students_number": hass.data[DOMAIN]["students_number"], + } + async_add_entities( + [ + VulcanCalendarEventDevice( + client, + data, + generate_entity_id( + ENTITY_ID_FORMAT, + f"vulcan_calendar_{data['student_info']['full_name']}", + hass=hass, + ), + ) + ], + ) + + +class VulcanCalendarEventDevice(CalendarEventDevice): + """A calendar event device.""" + + def __init__(self, client, data, entity_id): + """Create the Calendar event device.""" + self.student_info = data["student_info"] + self.data = VulcanCalendarData( + client, + self.student_info, + self.hass, + ) + self._event = None + self.entity_id = entity_id + self._unique_id = f"vulcan_calendar_{self.student_info['id']}" + + if data["students_number"] == 1: + self._attr_name = "Vulcan calendar" + self.device_name = "Calendar" + else: + self._attr_name = f"Vulcan calendar - {self.student_info['full_name']}" + self.device_name = f"{self.student_info['full_name']}: Calendar" + self._attr_unique_id = f"vulcan_calendar_{self.student_info['id']}" + self._attr_device_info = { + "identifiers": {(DOMAIN, f"calendar_{self.student_info['id']}")}, + "entry_type": DeviceEntryType.SERVICE, + "name": self.device_name, + "model": f"{self.student_info['full_name']} - {self.student_info['class']} {self.student_info['school']}", + "manufacturer": "Uonet +", + "configuration_url": f"https://uonetplus.vulcan.net.pl/{self.student_info['symbol']}", + } + + @property + def event(self): + """Return the next upcoming event.""" + return self._event + + async def async_get_events(self, hass, start_date, end_date): + """Get all events in a specific time frame.""" + return await self.data.async_get_events(hass, start_date, end_date) + + async def async_update(self): + """Update event data.""" + await self.data.async_update() + event = copy.deepcopy(self.data.event) + if event is None: + self._event = event + return + event["start"] = { + "dateTime": datetime.combine(event["date"], event["time"].from_) + .astimezone(dt.DEFAULT_TIME_ZONE) + .isoformat() + } + event["end"] = { + "dateTime": datetime.combine(event["date"], event["time"].to) + .astimezone(dt.DEFAULT_TIME_ZONE) + .isoformat() + } + self._event = event + + +class VulcanCalendarData: + """Class to utilize calendar service object to get next event.""" + + MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=DEFAULT_SCAN_INTERVAL) + + def __init__(self, client, student_info, hass): + """Set up how we are going to search the Vulcan calendar.""" + self.client = client + self.event = None + self.hass = hass + self.student_info = student_info + self._available = True + + async def async_get_events(self, hass, start_date, end_date): + """Get all events in a specific time frame.""" + try: + events = await get_lessons( + self.client, + date_from=start_date, + date_to=end_date, + ) + except VulcanAPIException as err: + if str(err) == "The certificate is not authorized.": + _LOGGER.error( + "The certificate is not authorized, please authorize integration again" + ) + raise ConfigEntryAuthFailed from err + _LOGGER.error("An API error has occurred: %s", err) + events = [] + except ClientConnectorError as err: + if self._available: + _LOGGER.warning( + "Connection error - please check your internet connection: %s", err + ) + events = [] + + event_list = [] + for item in events: + event = { + "uid": item["id"], + "start": { + "dateTime": datetime.combine( + item["date"], item["time"].from_ + ).strftime(DATE_STR_FORMAT) + }, + "end": { + "dateTime": datetime.combine( + item["date"], item["time"].to + ).strftime(DATE_STR_FORMAT) + }, + "summary": item["lesson"], + "location": item["room"], + "description": item["teacher"], + } + + event_list.append(event) + + return event_list + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): + """Get the latest data.""" + + try: + events = await get_lessons(self.client) + + if not self._available: + _LOGGER.info("Restored connection with API") + self._available = True + + if events == []: + events = await get_lessons( + self.client, + date_to=date.today() + timedelta(days=7), + ) + if events == []: + self.event = None + return + except VulcanAPIException as err: + if str(err) == "The certificate is not authorized.": + _LOGGER.error( + "The certificate is not authorized, please authorize integration again" + ) + raise ConfigEntryAuthFailed from err + _LOGGER.error("An API error has occurred: %s", err) + return + except ClientConnectorError as err: + if self._available: + _LOGGER.warning( + "Connection error - please check your internet connection: %s", err + ) + self._available = False + return + + new_event = min( + events, + key=lambda d: ( + datetime.combine(d["date"], d["time"].to) < datetime.now(), + abs(datetime.combine(d["date"], d["time"].to) - datetime.now()), + ), + ) + self.event = { + "uid": new_event["id"], + "date": new_event["date"], + "time": new_event["time"], + "summary": new_event["lesson"], + "location": new_event["room"], + "description": new_event["teacher"], + } diff --git a/homeassistant/components/vulcan/config_flow.py b/homeassistant/components/vulcan/config_flow.py new file mode 100644 index 00000000000..ef700560d73 --- /dev/null +++ b/homeassistant/components/vulcan/config_flow.py @@ -0,0 +1,342 @@ +"""Adds config flow for Vulcan.""" +import logging + +from aiohttp import ClientConnectionError +import voluptuous as vol +from vulcan import Account, Keystore, Vulcan +from vulcan._utils import VulcanAPIException + +from homeassistant import config_entries +from homeassistant.const import CONF_PIN, CONF_REGION, CONF_SCAN_INTERVAL, CONF_TOKEN +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +from . import DOMAIN +from .const import DEFAULT_SCAN_INTERVAL +from .register import register + +_LOGGER = logging.getLogger(__name__) + +LOGIN_SCHEMA = { + vol.Required(CONF_TOKEN): str, + vol.Required(CONF_REGION): str, + vol.Required(CONF_PIN): str, +} + + +class VulcanFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Uonet+ Vulcan config flow.""" + + VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return VulcanOptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input=None): + """Handle config flow.""" + if self._async_current_entries(): + return await self.async_step_add_next_config_entry() + + return await self.async_step_auth() + + async def async_step_auth(self, user_input=None, errors=None): + """Authorize integration.""" + + if user_input is not None: + try: + credentials = await register( + self.hass, + user_input[CONF_TOKEN], + user_input[CONF_REGION], + user_input[CONF_PIN], + ) + except VulcanAPIException as err: + if str(err) == "Invalid token!" or str(err) == "Invalid token.": + errors = {"base": "invalid_token"} + elif str(err) == "Expired token.": + errors = {"base": "expired_token"} + elif str(err) == "Invalid PIN.": + errors = {"base": "invalid_pin"} + else: + errors = {"base": "unknown"} + _LOGGER.error(err) + except RuntimeError as err: + if str(err) == "Internal Server Error (ArgumentException)": + errors = {"base": "invalid_symbol"} + else: + errors = {"base": "unknown"} + _LOGGER.error(err) + except ClientConnectionError as err: + errors = {"base": "cannot_connect"} + _LOGGER.error("Connection error: %s", err) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors = {"base": "unknown"} + if not errors: + account = credentials["account"] + keystore = credentials["keystore"] + client = Vulcan(keystore, account) + students = await client.get_students() + await client.close() + + if len(students) > 1: + # pylint:disable=attribute-defined-outside-init + self.account = account + self.keystore = keystore + self.students = students + return await self.async_step_select_student() + student = students[0] + await self.async_set_unique_id(str(student.pupil.id)) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{student.pupil.first_name} {student.pupil.last_name}", + data={ + "student_id": str(student.pupil.id), + "keystore": keystore.as_dict, + "account": account.as_dict, + }, + ) + + return self.async_show_form( + step_id="auth", + data_schema=vol.Schema(LOGIN_SCHEMA), + errors=errors, + ) + + async def async_step_select_student(self, user_input=None): + """Allow user to select student.""" + errors = {} + students_list = {} + if self.students is not None: + for student in self.students: + students_list[ + str(student.pupil.id) + ] = f"{student.pupil.first_name} {student.pupil.last_name}" + if user_input is not None: + student_id = user_input["student"] + await self.async_set_unique_id(str(student_id)) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=students_list[student_id], + data={ + "student_id": str(student_id), + "keystore": self.keystore.as_dict, + "account": self.account.as_dict, + }, + ) + + data_schema = { + vol.Required( + "student", + ): vol.In(students_list), + } + return self.async_show_form( + step_id="select_student", + data_schema=vol.Schema(data_schema), + errors=errors, + ) + + async def async_step_select_saved_credentials(self, user_input=None, errors=None): + """Allow user to select saved credentials.""" + credentials_list = {} + for entry in self.hass.config_entries.async_entries(DOMAIN): + credentials_list[entry.entry_id] = entry.data["account"]["UserName"] + + if user_input is not None: + entry = self.hass.config_entries.async_get_entry(user_input["credentials"]) + keystore = Keystore.load(entry.data["keystore"]) + account = Account.load(entry.data["account"]) + client = Vulcan(keystore, account) + try: + students = await client.get_students() + except VulcanAPIException as err: + if str(err) == "The certificate is not authorized.": + return await self.async_step_auth( + errors={"base": "expired_credentials"} + ) + _LOGGER.error(err) + return await self.async_step_auth(errors={"base": "unknown"}) + except ClientConnectionError as err: + _LOGGER.error("Connection error: %s", err) + return await self.async_step_select_saved_credentials( + errors={"base": "cannot_connect"} + ) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return await self.async_step_auth(errors={"base": "unknown"}) + finally: + await client.close() + if len(students) == 1: + student = students[0] + await self.async_set_unique_id(str(student.pupil.id)) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{student.pupil.first_name} {student.pupil.last_name}", + data={ + "student_id": str(student.pupil.id), + "keystore": keystore.as_dict, + "account": account.as_dict, + }, + ) + # pylint:disable=attribute-defined-outside-init + self.account = account + self.keystore = keystore + self.students = students + return await self.async_step_select_student() + + data_schema = { + vol.Required( + "credentials", + ): vol.In(credentials_list), + } + return self.async_show_form( + step_id="select_saved_credentials", + data_schema=vol.Schema(data_schema), + errors=errors, + ) + + async def async_step_add_next_config_entry(self, user_input=None): + """Flow initialized when user is adding next entry of that integration.""" + existing_entries = [] + for entry in self.hass.config_entries.async_entries(DOMAIN): + existing_entries.append(entry) + + errors = {} + if user_input is not None: + if user_input["use_saved_credentials"]: + if len(existing_entries) == 1: + keystore = Keystore.load(existing_entries[0].data["keystore"]) + account = Account.load(existing_entries[0].data["account"]) + client = Vulcan(keystore, account) + students = await client.get_students() + await client.close() + new_students = [] + existing_entry_ids = [] + for entry in self.hass.config_entries.async_entries(DOMAIN): + existing_entry_ids.append(entry.data["student_id"]) + for student in students: + if str(student.pupil.id) not in existing_entry_ids: + new_students.append(student) + if not new_students: + return self.async_abort(reason="all_student_already_configured") + if len(new_students) == 1: + await self.async_set_unique_id(str(new_students[0].pupil.id)) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{new_students[0].pupil.first_name} {new_students[0].pupil.last_name}", + data={ + "student_id": str(new_students[0].pupil.id), + "keystore": keystore.as_dict, + "account": account.as_dict, + }, + ) + # pylint:disable=attribute-defined-outside-init + self.account = account + self.keystore = keystore + self.students = new_students + return await self.async_step_select_student() + return await self.async_step_select_saved_credentials() + return await self.async_step_auth() + + data_schema = { + vol.Required("use_saved_credentials", default=True): bool, + } + return self.async_show_form( + step_id="add_next_config_entry", + data_schema=vol.Schema(data_schema), + errors=errors, + ) + + async def async_step_reauth(self, user_input=None): + """Reauthorize integration.""" + errors = {} + if user_input is not None: + try: + credentials = await register( + self.hass, + user_input[CONF_TOKEN], + user_input[CONF_REGION], + user_input[CONF_PIN], + ) + except VulcanAPIException as err: + if str(err) == "Invalid token!" or str(err) == "Invalid token.": + errors["base"] = "invalid_token" + elif str(err) == "Expired token.": + errors["base"] = "expired_token" + elif str(err) == "Invalid PIN.": + errors["base"] = "invalid_pin" + else: + errors["base"] = "unknown" + _LOGGER.error(err) + except RuntimeError as err: + if str(err) == "Internal Server Error (ArgumentException)": + errors["base"] = "invalid_symbol" + else: + errors["base"] = "unknown" + _LOGGER.error(err) + except ClientConnectionError as err: + errors["base"] = "cannot_connect" + _LOGGER.error("Connection error: %s", err) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + if not errors: + account = credentials["account"] + keystore = credentials["keystore"] + client = Vulcan(keystore, account) + students = await client.get_students() + await client.close() + existing_entries = [] + for entry in self.hass.config_entries.async_entries(DOMAIN): + existing_entries.append(entry) + for student in students: + for entry in existing_entries: + if str(student.pupil.id) == str(entry.data["student_id"]): + self.hass.config_entries.async_update_entry( + entry, + title=f"{student.pupil.first_name} {student.pupil.last_name}", + data={ + "student_id": str(student.pupil.id), + "keystore": keystore.as_dict, + "account": account.as_dict, + }, + ) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth", + data_schema=vol.Schema(LOGIN_SCHEMA), + errors=errors, + ) + + +class VulcanOptionsFlowHandler(config_entries.OptionsFlow): + """Config flow options for Uonet+ Vulcan.""" + + def __init__(self, config_entry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + errors = {} + + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options = { + vol.Optional( + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + ): cv.positive_int, + } + + return self.async_show_form( + step_id="init", data_schema=vol.Schema(options), errors=errors + ) diff --git a/homeassistant/components/vulcan/const.py b/homeassistant/components/vulcan/const.py new file mode 100644 index 00000000000..938cc4df8cd --- /dev/null +++ b/homeassistant/components/vulcan/const.py @@ -0,0 +1,4 @@ +"""Constants for the Vulcan integration.""" + +DOMAIN = "vulcan" +DEFAULT_SCAN_INTERVAL = 5 diff --git a/homeassistant/components/vulcan/fetch_data.py b/homeassistant/components/vulcan/fetch_data.py new file mode 100644 index 00000000000..04da8d125d7 --- /dev/null +++ b/homeassistant/components/vulcan/fetch_data.py @@ -0,0 +1,97 @@ +"""Support for fetching Vulcan data.""" + + +async def get_lessons(client, date_from=None, date_to=None): + """Support for fetching Vulcan lessons.""" + changes = {} + list_ans = [] + async for lesson in await client.data.get_changed_lessons( + date_from=date_from, date_to=date_to + ): + temp_dict = {} + _id = str(lesson.id) + temp_dict["id"] = lesson.id + temp_dict["number"] = lesson.time.position if lesson.time is not None else None + temp_dict["lesson"] = ( + lesson.subject.name if lesson.subject is not None else None + ) + temp_dict["room"] = lesson.room.code if lesson.room is not None else None + temp_dict["changes"] = lesson.changes + temp_dict["note"] = lesson.note + temp_dict["reason"] = lesson.reason + temp_dict["event"] = lesson.event + temp_dict["group"] = lesson.group + temp_dict["teacher"] = ( + lesson.teacher.display_name if lesson.teacher is not None else None + ) + temp_dict["from_to"] = ( + lesson.time.displayed_time if lesson.time is not None else None + ) + + changes[str(_id)] = temp_dict + + async for lesson in await client.data.get_lessons( + date_from=date_from, date_to=date_to + ): + temp_dict = {} + temp_dict["id"] = lesson.id + temp_dict["number"] = lesson.time.position + temp_dict["time"] = lesson.time + temp_dict["date"] = lesson.date.date + temp_dict["lesson"] = ( + lesson.subject.name if lesson.subject is not None else None + ) + if lesson.room is not None: + temp_dict["room"] = lesson.room.code + else: + temp_dict["room"] = "-" + temp_dict["visible"] = lesson.visible + temp_dict["changes"] = lesson.changes + temp_dict["group"] = lesson.group + temp_dict["reason"] = None + temp_dict["teacher"] = ( + lesson.teacher.display_name if lesson.teacher is not None else None + ) + temp_dict["from_to"] = ( + lesson.time.displayed_time if lesson.time is not None else None + ) + if temp_dict["changes"] is None: + temp_dict["changes"] = "" + elif temp_dict["changes"].type == 1: + temp_dict["lesson"] = f"Lekcja odwołana ({temp_dict['lesson']})" + temp_dict["changes_info"] = f"Lekcja odwołana ({temp_dict['lesson']})" + if str(temp_dict["changes"].id) in changes: + temp_dict["reason"] = changes[str(temp_dict["changes"].id)]["reason"] + elif temp_dict["changes"].type == 2: + temp_dict["lesson"] = f"{temp_dict['lesson']} (Zastępstwo)" + temp_dict["teacher"] = changes[str(temp_dict["changes"].id)]["teacher"] + if str(temp_dict["changes"].id) in changes: + temp_dict["teacher"] = changes[str(temp_dict["changes"].id)]["teacher"] + temp_dict["reason"] = changes[str(temp_dict["changes"].id)]["reason"] + elif temp_dict["changes"].type == 4: + temp_dict["lesson"] = f"Lekcja odwołana ({temp_dict['lesson']})" + if str(temp_dict["changes"].id) in changes: + temp_dict["reason"] = changes[str(temp_dict["changes"].id)]["reason"] + if temp_dict["visible"]: + list_ans.append(temp_dict) + + return list_ans + + +async def get_student_info(client, student_id): + """Support for fetching Student info by student id.""" + student_info = {} + for student in await client.get_students(): + if str(student.pupil.id) == str(student_id): + student_info["first_name"] = student.pupil.first_name + if student.pupil.second_name: + student_info["second_name"] = student.pupil.second_name + student_info["last_name"] = student.pupil.last_name + student_info[ + "full_name" + ] = f"{student.pupil.first_name} {student.pupil.last_name}" + student_info["id"] = student.pupil.id + student_info["class"] = student.class_ + student_info["school"] = student.school.name + student_info["symbol"] = student.symbol + return student_info diff --git a/homeassistant/components/vulcan/manifest.json b/homeassistant/components/vulcan/manifest.json new file mode 100644 index 00000000000..6449f07a3fb --- /dev/null +++ b/homeassistant/components/vulcan/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "vulcan", + "name": "Uonet+ Vulcan", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/vulcan", + "requirements": ["vulcan-api==2.0.3"], + "dependencies": [], + "codeowners": ["@Antoni-Czaplicki"], + "iot_class": "cloud_polling", + "quality_scale": "platinum" +} diff --git a/homeassistant/components/vulcan/register.py b/homeassistant/components/vulcan/register.py new file mode 100644 index 00000000000..802805d1db8 --- /dev/null +++ b/homeassistant/components/vulcan/register.py @@ -0,0 +1,13 @@ +"""Support for register Vulcan account.""" +from functools import partial + +from vulcan import Account, Keystore + + +async def register(hass, token, symbol, pin): + """Register integration and save credentials.""" + keystore = await hass.async_add_executor_job( + partial(Keystore.create, device_model="Home Assistant") + ) + account = await Account.register(keystore, token, symbol, pin) + return {"account": account, "keystore": keystore} diff --git a/homeassistant/components/vulcan/strings.json b/homeassistant/components/vulcan/strings.json new file mode 100644 index 00000000000..abb3dce7c7f --- /dev/null +++ b/homeassistant/components/vulcan/strings.json @@ -0,0 +1,69 @@ +{ + "config": { + "abort": { + "already_configured": "That student has already been added.", + "all_student_already_configured": "All students have already been added.", + "reauth_successful": "Reauth successful" + }, + "error": { + "unknown": "Unknown error occurred", + "invalid_token": "Invalid token", + "expired_token": "Expired token - please generate a new token", + "invalid_pin": "Invalid pin", + "invalid_symbol": "Invalid symbol", + "expired_credentials": "Expired credentials - please create new on Vulcan mobile app registration page", + "cannot_connect": "Connection error - please check your internet connection" + }, + "step": { + "auth": { + "description": "Login to your Vulcan Account using mobile app registration page.", + "data": { + "token": "Token", + "region": "Symbol", + "pin": "Pin" + } + }, + "reauth": { + "description": "Login to your Vulcan Account using mobile app registration page.", + "data": { + "token": "Token", + "region": "Symbol", + "pin": "Pin" + } + }, + "select_student": { + "description": "Select student, you can add more students by adding integration again.", + "data": { + "student_name": "Select student" + } + }, + "select_saved_credentials": { + "description": "Select saved credentials.", + "data": { + "credentials": "Login" + } + }, + "add_next_config_entry": { + "description": "Add another student.", + "data": { + "use_saved_credentials": "Use saved credentials" + } + } + } + }, + "options": { + "error": { + "error": "Error occurred" + }, + "step": { + "init": { + "data": { + "message_notify": "Show notifications when new message received", + "attendance_notify": "Show notifications about the latest attendance entries", + "grade_notify": "Show notifications about the latest grades", + "scan_interval": "Update interval (in minutes)" + } + } + } + } +} diff --git a/homeassistant/components/vulcan/translations/en.json b/homeassistant/components/vulcan/translations/en.json new file mode 100644 index 00000000000..abb3dce7c7f --- /dev/null +++ b/homeassistant/components/vulcan/translations/en.json @@ -0,0 +1,69 @@ +{ + "config": { + "abort": { + "already_configured": "That student has already been added.", + "all_student_already_configured": "All students have already been added.", + "reauth_successful": "Reauth successful" + }, + "error": { + "unknown": "Unknown error occurred", + "invalid_token": "Invalid token", + "expired_token": "Expired token - please generate a new token", + "invalid_pin": "Invalid pin", + "invalid_symbol": "Invalid symbol", + "expired_credentials": "Expired credentials - please create new on Vulcan mobile app registration page", + "cannot_connect": "Connection error - please check your internet connection" + }, + "step": { + "auth": { + "description": "Login to your Vulcan Account using mobile app registration page.", + "data": { + "token": "Token", + "region": "Symbol", + "pin": "Pin" + } + }, + "reauth": { + "description": "Login to your Vulcan Account using mobile app registration page.", + "data": { + "token": "Token", + "region": "Symbol", + "pin": "Pin" + } + }, + "select_student": { + "description": "Select student, you can add more students by adding integration again.", + "data": { + "student_name": "Select student" + } + }, + "select_saved_credentials": { + "description": "Select saved credentials.", + "data": { + "credentials": "Login" + } + }, + "add_next_config_entry": { + "description": "Add another student.", + "data": { + "use_saved_credentials": "Use saved credentials" + } + } + } + }, + "options": { + "error": { + "error": "Error occurred" + }, + "step": { + "init": { + "data": { + "message_notify": "Show notifications when new message received", + "attendance_notify": "Show notifications about the latest attendance entries", + "grade_notify": "Show notifications about the latest grades", + "scan_interval": "Update interval (in minutes)" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7cfd326a249..998e88000bf 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -376,6 +376,7 @@ FLOWS = { "vizio", "vlc_telnet", "volumio", + "vulcan", "wallbox", "watttime", "waze_travel_time", diff --git a/requirements_all.txt b/requirements_all.txt index ee5fb6db55b..60316fb0ec4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2378,6 +2378,9 @@ vsure==1.7.3 # homeassistant.components.vasttrafik vtjp==0.1.14 +# homeassistant.components.vulcan +vulcan-api==2.0.3 + # homeassistant.components.vultr vultr==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eaff1d388ac..52dc5a2b293 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1531,6 +1531,9 @@ vilfo-api-client==0.3.2 # homeassistant.components.verisure vsure==1.7.3 +# homeassistant.components.vulcan +vulcan-api==2.0.3 + # homeassistant.components.vultr vultr==0.1.2 diff --git a/tests/components/vulcan/__init__.py b/tests/components/vulcan/__init__.py new file mode 100644 index 00000000000..6f165c36c36 --- /dev/null +++ b/tests/components/vulcan/__init__.py @@ -0,0 +1 @@ +"""Tests for the Uonet+ Vulcan integration.""" diff --git a/tests/components/vulcan/fixtures/fake_config_entry_data.json b/tests/components/vulcan/fixtures/fake_config_entry_data.json new file mode 100644 index 00000000000..4dfcd630140 --- /dev/null +++ b/tests/components/vulcan/fixtures/fake_config_entry_data.json @@ -0,0 +1,16 @@ +{ + "student_id": "123", + "keystore": { + "Certificate": "certificate", + "DeviceModel": "Home Assistant", + "Fingerprint": "fingerprint", + "FirebaseToken": "firebase_token", + "PrivateKey": "private_key" + }, + "account": { + "LoginId": 0, + "RestURL": "", + "UserLogin": "example@example.com", + "UserName": "example@example.com" + } +} diff --git a/tests/components/vulcan/fixtures/fake_student_1.json b/tests/components/vulcan/fixtures/fake_student_1.json new file mode 100644 index 00000000000..0e6c79e4b03 --- /dev/null +++ b/tests/components/vulcan/fixtures/fake_student_1.json @@ -0,0 +1,29 @@ +{ + "TopLevelPartition": "", + "Partition": "", + "ClassDisplay": "", + "Unit": { + "Id": 1, + "Symbol": "", + "Short": "", + "RestURL": "", + "Name": "", + "DisplayName": "" + }, + "ConstituentUnit": { + "Id": 1, + "Short": "", + "Name": "", + "Address": "" + }, + "Pupil": { + "Id": 0, + "LoginId": 0, + "LoginValue": "", + "FirstName": "Jan", + "SecondName": "Maciej", + "Surname": "Kowalski", + "Sex": true + }, + "Periods": [] +} diff --git a/tests/components/vulcan/fixtures/fake_student_2.json b/tests/components/vulcan/fixtures/fake_student_2.json new file mode 100644 index 00000000000..0176b72d4fc --- /dev/null +++ b/tests/components/vulcan/fixtures/fake_student_2.json @@ -0,0 +1,29 @@ +{ + "TopLevelPartition": "", + "Partition": "", + "ClassDisplay": "", + "Unit": { + "Id": 1, + "Symbol": "", + "Short": "", + "RestURL": "", + "Name": "", + "DisplayName": "" + }, + "ConstituentUnit": { + "Id": 1, + "Short": "", + "Name": "", + "Address": "" + }, + "Pupil": { + "Id": 1, + "LoginId": 1, + "LoginValue": "", + "FirstName": "Magda", + "SecondName": "", + "Surname": "Kowalska", + "Sex": false + }, + "Periods": [] +} diff --git a/tests/components/vulcan/test_config_flow.py b/tests/components/vulcan/test_config_flow.py new file mode 100644 index 00000000000..91f0dd769c8 --- /dev/null +++ b/tests/components/vulcan/test_config_flow.py @@ -0,0 +1,935 @@ +"""Test the Uonet+ Vulcan config flow.""" +import json +from unittest import mock +from unittest.mock import patch + +from vulcan import Account +from vulcan.model import Student + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.vulcan import config_flow, const, register +from homeassistant.components.vulcan.config_flow import ( + ClientConnectionError, + Keystore, + VulcanAPIException, +) +from homeassistant.const import CONF_PIN, CONF_REGION, CONF_SCAN_INTERVAL, CONF_TOKEN + +from tests.common import MockConfigEntry, load_fixture + +fake_keystore = Keystore("", "", "", "", "") +fake_account = Account( + login_id=1, + user_login="example@example.com", + user_name="example@example.com", + rest_url="rest_url", +) + + +async def test_show_form(hass): + """Test that the form is served with no input.""" + flow = config_flow.VulcanFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=None) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + + +@mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") +@mock.patch("homeassistant.components.vulcan.config_flow.Account.register") +@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") +async def test_config_flow_auth_success( + mock_keystore, mock_account, mock_student, hass +): + """Test a successful config flow initialized by the user.""" + mock_keystore.return_value = fake_keystore + mock_account.return_value = fake_account + mock_student.return_value = [ + Student.load(load_fixture("fake_student_1.json", "vulcan")) + ] + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] is None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Jan Kowalski" + + +@mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") +@mock.patch("homeassistant.components.vulcan.config_flow.Account.register") +@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") +async def test_config_flow_auth_success_with_multiple_students( + mock_keystore, mock_account, mock_student, hass +): + """Test a successful config flow with multiple students.""" + mock_keystore.return_value = fake_keystore + mock_account.return_value = fake_account + mock_student.return_value = [ + Student.load(student) + for student in [load_fixture("fake_student_1.json", "vulcan")] + + [load_fixture("fake_student_2.json", "vulcan")] + ] + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "select_student" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"student": "0"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Jan Kowalski" + + +@mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") +@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") +@mock.patch("homeassistant.components.vulcan.config_flow.Account.register") +async def test_config_flow_reauth_success( + mock_account, mock_keystore, mock_student, hass +): + """Test a successful config flow reauth.""" + mock_keystore.return_value = fake_keystore + mock_account.return_value = fake_account + mock_student.return_value = [ + Student.load(load_fixture("fake_student_1.json", "vulcan")) + ] + MockConfigEntry( + domain=const.DOMAIN, + unique_id="0", + data={"student_id": "0", "login": "example@example.com"}, + ).add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_REAUTH} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + + +@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") +@mock.patch("homeassistant.components.vulcan.config_flow.Account.register") +async def test_config_flow_reauth_with_errors(mock_account, mock_keystore, hass): + """Test reauth config flow with errors.""" + mock_keystore.return_value = fake_keystore + mock_account.return_value = fake_account + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_REAUTH} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + assert result["errors"] == {} + with patch( + "homeassistant.components.vulcan.config_flow.Account.register", + side_effect=VulcanAPIException("Invalid token."), + ): + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + assert result["errors"] == {"base": "invalid_token"} + + with patch( + "homeassistant.components.vulcan.config_flow.Account.register", + side_effect=VulcanAPIException("Expired token."), + ): + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + assert result["errors"] == {"base": "expired_token"} + + with patch( + "homeassistant.components.vulcan.config_flow.Account.register", + side_effect=VulcanAPIException("Invalid PIN."), + ): + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + assert result["errors"] == {"base": "invalid_pin"} + + with patch( + "homeassistant.components.vulcan.config_flow.Account.register", + side_effect=VulcanAPIException("Unknown error"), + ): + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + assert result["errors"] == {"base": "unknown"} + + with patch( + "homeassistant.components.vulcan.config_flow.Account.register", + side_effect=RuntimeError("Internal Server Error (ArgumentException)"), + ): + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + assert result["errors"] == {"base": "invalid_symbol"} + + with patch( + "homeassistant.components.vulcan.config_flow.Account.register", + side_effect=RuntimeError("Unknown error"), + ): + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + assert result["errors"] == {"base": "unknown"} + + with patch( + "homeassistant.components.vulcan.config_flow.Account.register", + side_effect=ClientConnectionError, + ): + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + assert result["errors"] == {"base": "cannot_connect"} + + with patch( + "homeassistant.components.vulcan.config_flow.Account.register", + side_effect=Exception, + ): + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + assert result["errors"] == {"base": "unknown"} + + +@mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") +@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") +@mock.patch("homeassistant.components.vulcan.config_flow.Account.register") +async def test_multiple_config_entries(mock_account, mock_keystore, mock_student, hass): + """Test a successful config flow for multiple config entries.""" + mock_keystore.return_value = fake_keystore + mock_account.return_value = fake_account + mock_student.return_value = [ + Student.load(load_fixture("fake_student_1.json", "vulcan")) + ] + MockConfigEntry( + domain=const.DOMAIN, + unique_id="123456", + data=json.loads(load_fixture("fake_config_entry_data.json", "vulcan")), + ).add_to_hass(hass) + await register.register(hass, "token", "region", "000000") + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "add_next_config_entry" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"use_saved_credentials": False}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Jan Kowalski" + + +@mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") +async def test_multiple_config_entries_using_saved_credentials(mock_student, hass): + """Test a successful config flow for multiple config entries using saved credentials.""" + mock_student.return_value = [ + Student.load(load_fixture("fake_student_1.json", "vulcan")) + ] + MockConfigEntry( + domain=const.DOMAIN, + unique_id="123456", + data=json.loads(load_fixture("fake_config_entry_data.json", "vulcan")), + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "add_next_config_entry" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"use_saved_credentials": True}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Jan Kowalski" + + +@mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") +async def test_multiple_config_entries_using_saved_credentials_2(mock_student, hass): + """Test a successful config flow for multiple config entries using saved credentials (different situation).""" + mock_student.return_value = [ + Student.load(load_fixture("fake_student_1.json", "vulcan")) + ] + [Student.load(load_fixture("fake_student_2.json", "vulcan"))] + MockConfigEntry( + domain=const.DOMAIN, + unique_id="123456", + data=json.loads(load_fixture("fake_config_entry_data.json", "vulcan")), + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "add_next_config_entry" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"use_saved_credentials": True}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "select_student" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"student": "0"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Jan Kowalski" + + +@mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") +async def test_multiple_config_entries_using_saved_credentials_3(mock_student, hass): + """Test a successful config flow for multiple config entries using saved credentials.""" + mock_student.return_value = [ + Student.load(load_fixture("fake_student_1.json", "vulcan")) + ] + MockConfigEntry( + entry_id="456", + domain=const.DOMAIN, + unique_id="234567", + data=json.loads(load_fixture("fake_config_entry_data.json", "vulcan")) + | {"student_id": "456"}, + ).add_to_hass(hass) + MockConfigEntry( + entry_id="123", + domain=const.DOMAIN, + unique_id="123456", + data=json.loads(load_fixture("fake_config_entry_data.json", "vulcan")), + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "add_next_config_entry" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"use_saved_credentials": True}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "select_saved_credentials" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"credentials": "123"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Jan Kowalski" + + +@mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") +async def test_multiple_config_entries_using_saved_credentials_4(mock_student, hass): + """Test a successful config flow for multiple config entries using saved credentials (different situation).""" + mock_student.return_value = [ + Student.load(load_fixture("fake_student_1.json", "vulcan")) + ] + [Student.load(load_fixture("fake_student_2.json", "vulcan"))] + MockConfigEntry( + entry_id="456", + domain=const.DOMAIN, + unique_id="234567", + data=json.loads(load_fixture("fake_config_entry_data.json", "vulcan")) + | {"student_id": "456"}, + ).add_to_hass(hass) + MockConfigEntry( + entry_id="123", + domain=const.DOMAIN, + unique_id="123456", + data=json.loads(load_fixture("fake_config_entry_data.json", "vulcan")), + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "add_next_config_entry" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"use_saved_credentials": True}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "select_saved_credentials" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"credentials": "123"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "select_student" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"student": "0"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Jan Kowalski" + + +async def test_multiple_config_entries_without_valid_saved_credentials(hass): + """Test a unsuccessful config flow for multiple config entries without valid saved credentials.""" + MockConfigEntry( + entry_id="456", + domain=const.DOMAIN, + unique_id="234567", + data=json.loads(load_fixture("fake_config_entry_data.json", "vulcan")) + | {"student_id": "456"}, + ).add_to_hass(hass) + MockConfigEntry( + entry_id="123", + domain=const.DOMAIN, + unique_id="123456", + data=json.loads(load_fixture("fake_config_entry_data.json", "vulcan")), + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "add_next_config_entry" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"use_saved_credentials": True}, + ) + with patch( + "homeassistant.components.vulcan.config_flow.Vulcan.get_students", + side_effect=VulcanAPIException("The certificate is not authorized."), + ): + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "select_saved_credentials" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"credentials": "123"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] == {"base": "expired_credentials"} + + +async def test_multiple_config_entries_using_saved_credentials_with_connections_issues( + hass, +): + """Test a unsuccessful config flow for multiple config entries without valid saved credentials.""" + MockConfigEntry( + entry_id="456", + domain=const.DOMAIN, + unique_id="234567", + data=json.loads(load_fixture("fake_config_entry_data.json", "vulcan")) + | {"student_id": "456"}, + ).add_to_hass(hass) + MockConfigEntry( + entry_id="123", + domain=const.DOMAIN, + unique_id="123456", + data=json.loads(load_fixture("fake_config_entry_data.json", "vulcan")), + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "add_next_config_entry" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"use_saved_credentials": True}, + ) + with patch( + "homeassistant.components.vulcan.config_flow.Vulcan.get_students", + side_effect=ClientConnectionError, + ): + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "select_saved_credentials" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"credentials": "123"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "select_saved_credentials" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_multiple_config_entries_using_saved_credentials_with_unknown_error(hass): + """Test a unsuccessful config flow for multiple config entries without valid saved credentials.""" + MockConfigEntry( + entry_id="456", + domain=const.DOMAIN, + unique_id="234567", + data=json.loads(load_fixture("fake_config_entry_data.json", "vulcan")) + | {"student_id": "456"}, + ).add_to_hass(hass) + MockConfigEntry( + entry_id="123", + domain=const.DOMAIN, + unique_id="123456", + data=json.loads(load_fixture("fake_config_entry_data.json", "vulcan")), + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "add_next_config_entry" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"use_saved_credentials": True}, + ) + with patch( + "homeassistant.components.vulcan.config_flow.Vulcan.get_students", + side_effect=Exception, + ): + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "select_saved_credentials" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"credentials": "123"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] == {"base": "unknown"} + + +async def test_multiple_config_entries_using_saved_credentials_with_unknown_api_error( + hass, +): + """Test a unsuccessful config flow for multiple config entries without valid saved credentials.""" + MockConfigEntry( + entry_id="456", + domain=const.DOMAIN, + unique_id="234567", + data=json.loads(load_fixture("fake_config_entry_data.json", "vulcan")) + | {"student_id": "456"}, + ).add_to_hass(hass) + MockConfigEntry( + entry_id="123", + domain=const.DOMAIN, + unique_id="123456", + data=json.loads(load_fixture("fake_config_entry_data.json", "vulcan")), + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "add_next_config_entry" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"use_saved_credentials": True}, + ) + with patch( + "homeassistant.components.vulcan.config_flow.Vulcan.get_students", + side_effect=VulcanAPIException("Unknown error"), + ): + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "select_saved_credentials" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"credentials": "123"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] == {"base": "unknown"} + + +@mock.patch("homeassistant.components.vulcan.config_flow.Vulcan.get_students") +@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") +@mock.patch("homeassistant.components.vulcan.config_flow.Account.register") +async def test_student_already_exists(mock_account, mock_keystore, mock_student, hass): + """Test config entry when student's entry already exists.""" + mock_keystore.return_value = fake_keystore + mock_account.return_value = fake_account + mock_student.return_value = [ + Student.load(load_fixture("fake_student_1.json", "vulcan")) + ] + MockConfigEntry( + domain=const.DOMAIN, + unique_id="0", + data=json.loads(load_fixture("fake_config_entry_data.json", "vulcan")) + | {"student_id": "0"}, + ).add_to_hass(hass) + + await register.register(hass, "token", "region", "000000") + + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "add_next_config_entry" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"use_saved_credentials": True}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "all_student_already_configured" + + +@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") +async def test_config_flow_auth_invalid_token(mock_keystore, hass): + """Test a config flow initialized by the user using invalid token.""" + mock_keystore.return_value = fake_keystore + with patch( + "homeassistant.components.vulcan.config_flow.Account.register", + side_effect=VulcanAPIException("Invalid token."), + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "3S20000", CONF_REGION: "region", CONF_PIN: "000000"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] == {"base": "invalid_token"} + + +@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") +async def test_config_flow_auth_invalid_region(mock_keystore, hass): + """Test a config flow initialized by the user using invalid region.""" + mock_keystore.return_value = fake_keystore + with patch( + "homeassistant.components.vulcan.config_flow.Account.register", + side_effect=RuntimeError("Internal Server Error (ArgumentException)"), + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "3S10000", CONF_REGION: "invalid_region", CONF_PIN: "000000"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] == {"base": "invalid_symbol"} + + +@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") +async def test_config_flow_auth_invalid_pin(mock_keystore, hass): + """Test a config flow initialized by the with invalid pin.""" + mock_keystore.return_value = fake_keystore + with patch( + "homeassistant.components.vulcan.config_flow.Account.register", + side_effect=VulcanAPIException("Invalid PIN."), + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "3S10000", CONF_REGION: "region", CONF_PIN: "000000"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] == {"base": "invalid_pin"} + + +@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") +async def test_config_flow_auth_expired_token(mock_keystore, hass): + """Test a config flow initialized by the with expired token.""" + mock_keystore.return_value = fake_keystore + with patch( + "homeassistant.components.vulcan.config_flow.Account.register", + side_effect=VulcanAPIException("Expired token."), + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "3S10000", CONF_REGION: "region", CONF_PIN: "000000"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] == {"base": "expired_token"} + + +@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") +async def test_config_flow_auth_api_unknown_error(mock_keystore, hass): + """Test a config flow with unknown API error.""" + mock_keystore.return_value = fake_keystore + with patch( + "homeassistant.components.vulcan.config_flow.Account.register", + side_effect=VulcanAPIException("Unknown error"), + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "3S10000", CONF_REGION: "region", CONF_PIN: "000000"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] == {"base": "unknown"} + + +@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") +async def test_config_flow_auth_api_unknown_runtime_error(mock_keystore, hass): + """Test a config flow with runtime error.""" + mock_keystore.return_value = fake_keystore + with patch( + "homeassistant.components.vulcan.config_flow.Account.register", + side_effect=RuntimeError("Unknown error"), + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "3S10000", CONF_REGION: "region", CONF_PIN: "000000"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] == {"base": "unknown"} + + +@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") +async def test_config_flow_auth_connection_error(mock_keystore, hass): + """Test a config flow with connection error.""" + mock_keystore.return_value = fake_keystore + with patch( + "homeassistant.components.vulcan.config_flow.Account.register", + side_effect=ClientConnectionError, + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "3S10000", CONF_REGION: "region", CONF_PIN: "000000"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] == {"base": "cannot_connect"} + + +@mock.patch("homeassistant.components.vulcan.config_flow.Keystore.create") +async def test_config_flow_auth_unknown_error(mock_keystore, hass): + """Test a config flow with unknown error.""" + mock_keystore.return_value = fake_keystore + with patch( + "homeassistant.components.vulcan.config_flow.Account.register", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "3S10000", CONF_REGION: "invalid_region", CONF_PIN: "000000"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "auth" + assert result["errors"] == {"base": "unknown"} + + +@mock.patch("homeassistant.components.vulcan.Vulcan.get_students") +async def test_options_flow(mock_student, hass): + """Test config flow options.""" + mock_student.return_value = [ + Student.load(load_fixture("fake_student_1.json", "vulcan")) + ] + config_entry = MockConfigEntry( + domain=const.DOMAIN, + unique_id="0", + data=json.loads(load_fixture("fake_config_entry_data.json", "vulcan")), + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_SCAN_INTERVAL: 2137} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == {CONF_SCAN_INTERVAL: 2137}