diff --git a/.strict-typing b/.strict-typing index d483d04f70263a..cacab1a415115d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -203,6 +203,7 @@ homeassistant.components.feedreader.* homeassistant.components.file_upload.* homeassistant.components.filesize.* homeassistant.components.filter.* +homeassistant.components.firefly_iii.* homeassistant.components.fitbit.* homeassistant.components.flexit_bacnet.* homeassistant.components.flux_led.* diff --git a/CODEOWNERS b/CODEOWNERS index 47ab063477a02c..5b1c185bbf75ea 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -492,6 +492,8 @@ build.json @home-assistant/supervisor /tests/components/filesize/ @gjohansson-ST /homeassistant/components/filter/ @dgomes /tests/components/filter/ @dgomes +/homeassistant/components/firefly_iii/ @erwindouna +/tests/components/firefly_iii/ @erwindouna /homeassistant/components/fireservicerota/ @cyberjunky /tests/components/fireservicerota/ @cyberjunky /homeassistant/components/firmata/ @DaAwesomeP diff --git a/homeassistant/components/firefly_iii/__init__.py b/homeassistant/components/firefly_iii/__init__.py new file mode 100644 index 00000000000000..6a778ae8c8aefa --- /dev/null +++ b/homeassistant/components/firefly_iii/__init__.py @@ -0,0 +1,27 @@ +"""The Firefly III integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: FireflyConfigEntry) -> bool: + """Set up Firefly III from a config entry.""" + + coordinator = FireflyDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: FireflyConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/firefly_iii/config_flow.py b/homeassistant/components/firefly_iii/config_flow.py new file mode 100644 index 00000000000000..ceebaa914a914a --- /dev/null +++ b/homeassistant/components/firefly_iii/config_flow.py @@ -0,0 +1,97 @@ +"""Config flow for the Firefly III integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pyfirefly import ( + Firefly, + FireflyAuthenticationError, + FireflyConnectionError, + FireflyTimeoutError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Optional(CONF_VERIFY_SSL, default=True): bool, + vol.Required(CONF_API_KEY): str, + } +) + + +async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool: + """Validate the user input allows us to connect.""" + + try: + client = Firefly( + api_url=data[CONF_URL], + api_key=data[CONF_API_KEY], + session=async_get_clientsession(hass), + ) + await client.get_about() + except FireflyAuthenticationError: + raise InvalidAuth from None + except FireflyConnectionError as err: + raise CannotConnect from err + except FireflyTimeoutError as err: + raise FireflyClientTimeout from err + + return True + + +class FireflyConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Firefly III.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) + try: + await _validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except FireflyClientTimeout: + errors["base"] = "timeout_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=user_input[CONF_URL], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class FireflyClientTimeout(HomeAssistantError): + """Error to indicate a timeout occurred.""" diff --git a/homeassistant/components/firefly_iii/const.py b/homeassistant/components/firefly_iii/const.py new file mode 100644 index 00000000000000..d8de96ddc5d1c5 --- /dev/null +++ b/homeassistant/components/firefly_iii/const.py @@ -0,0 +1,6 @@ +"""Constants for the Firefly III integration.""" + +DOMAIN = "firefly_iii" + +MANUFACTURER = "Firefly III" +NAME = "Firefly III" diff --git a/homeassistant/components/firefly_iii/coordinator.py b/homeassistant/components/firefly_iii/coordinator.py new file mode 100644 index 00000000000000..3b64b3197cdb81 --- /dev/null +++ b/homeassistant/components/firefly_iii/coordinator.py @@ -0,0 +1,137 @@ +"""Data Update Coordinator for Firefly III integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta +import logging + +from aiohttp import CookieJar +from pyfirefly import ( + Firefly, + FireflyAuthenticationError, + FireflyConnectionError, + FireflyTimeoutError, +) +from pyfirefly.models import Account, Bill, Budget, Category, Currency + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type FireflyConfigEntry = ConfigEntry[FireflyDataUpdateCoordinator] + +DEFAULT_SCAN_INTERVAL = timedelta(minutes=5) + + +@dataclass +class FireflyCoordinatorData: + """Data structure for Firefly III coordinator data.""" + + accounts: list[Account] + categories: list[Category] + category_details: list[Category] + budgets: list[Budget] + bills: list[Bill] + primary_currency: Currency + + +class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]): + """Coordinator to manage data updates for Firefly III integration.""" + + config_entry: FireflyConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: FireflyConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, + ) + self.firefly = Firefly( + api_url=self.config_entry.data[CONF_URL], + api_key=self.config_entry.data[CONF_API_KEY], + session=async_create_clientsession( + self.hass, + self.config_entry.data[CONF_VERIFY_SSL], + cookie_jar=CookieJar(unsafe=True), + ), + ) + + async def _async_setup(self) -> None: + """Set up the coordinator.""" + try: + await self.firefly.get_about() + except FireflyAuthenticationError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"error": repr(err)}, + ) from err + except FireflyConnectionError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except FireflyTimeoutError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="timeout_connect", + translation_placeholders={"error": repr(err)}, + ) from err + + async def _async_update_data(self) -> FireflyCoordinatorData: + """Fetch data from Firefly III API.""" + now = datetime.now() + start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + end_date = now + + try: + accounts = await self.firefly.get_accounts() + categories = await self.firefly.get_categories() + category_details = [ + await self.firefly.get_category( + category_id=int(category.id), start=start_date, end=end_date + ) + for category in categories + ] + primary_currency = await self.firefly.get_currency_primary() + budgets = await self.firefly.get_budgets() + bills = await self.firefly.get_bills() + except FireflyAuthenticationError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"error": repr(err)}, + ) from err + except FireflyConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except FireflyTimeoutError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="timeout_connect", + translation_placeholders={"error": repr(err)}, + ) from err + + return FireflyCoordinatorData( + accounts=accounts, + categories=categories, + category_details=category_details, + budgets=budgets, + bills=bills, + primary_currency=primary_currency, + ) diff --git a/homeassistant/components/firefly_iii/entity.py b/homeassistant/components/firefly_iii/entity.py new file mode 100644 index 00000000000000..0281065a6e70ec --- /dev/null +++ b/homeassistant/components/firefly_iii/entity.py @@ -0,0 +1,40 @@ +"""Base entity for Firefly III integration.""" + +from __future__ import annotations + +from yarl import URL + +from homeassistant.const import CONF_URL +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import FireflyDataUpdateCoordinator + + +class FireflyBaseEntity(CoordinatorEntity[FireflyDataUpdateCoordinator]): + """Base class for Firefly III entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: FireflyDataUpdateCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize a Firefly entity.""" + super().__init__(coordinator) + + self.entity_description = entity_description + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer=MANUFACTURER, + configuration_url=URL(coordinator.config_entry.data[CONF_URL]), + identifiers={ + ( + DOMAIN, + f"{coordinator.config_entry.entry_id}_{self.entity_description.key}", + ) + }, + ) diff --git a/homeassistant/components/firefly_iii/icons.json b/homeassistant/components/firefly_iii/icons.json new file mode 100644 index 00000000000000..9a8498041924d2 --- /dev/null +++ b/homeassistant/components/firefly_iii/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "sensor": { + "account_type": { + "default": "mdi:bank", + "state": { + "expense": "mdi:cash-minus", + "revenue": "mdi:cash-plus", + "asset": "mdi:account-cash", + "liability": "mdi:hand-coin" + } + }, + "category": { + "default": "mdi:label" + } + } + } +} diff --git a/homeassistant/components/firefly_iii/manifest.json b/homeassistant/components/firefly_iii/manifest.json new file mode 100644 index 00000000000000..18f9f794331558 --- /dev/null +++ b/homeassistant/components/firefly_iii/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "firefly_iii", + "name": "Firefly III", + "codeowners": ["@erwindouna"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/firefly_iii", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["pyfirefly==0.1.5"] +} diff --git a/homeassistant/components/firefly_iii/quality_scale.yaml b/homeassistant/components/firefly_iii/quality_scale.yaml new file mode 100644 index 00000000000000..a985e389588507 --- /dev/null +++ b/homeassistant/components/firefly_iii/quality_scale.yaml @@ -0,0 +1,68 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: exempt + comment: | + No explicit parallel updates are defined. + reauthentication-flow: + status: todo + comment: | + No reauthentication flow is defined. It will be done in a next iteration. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/firefly_iii/sensor.py b/homeassistant/components/firefly_iii/sensor.py new file mode 100644 index 00000000000000..f73238d7b2e164 --- /dev/null +++ b/homeassistant/components/firefly_iii/sensor.py @@ -0,0 +1,142 @@ +"""Sensor platform for Firefly III integration.""" + +from __future__ import annotations + +from pyfirefly.models import Account, Category + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.components.sensor.const import SensorDeviceClass +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator +from .entity import FireflyBaseEntity + +ACCOUNT_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="account_type", + translation_key="account", + device_class=SensorDeviceClass.MONETARY, + state_class=SensorStateClass.TOTAL, + ), +) + +CATEGORY_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="category", + translation_key="category", + device_class=SensorDeviceClass.MONETARY, + state_class=SensorStateClass.TOTAL, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FireflyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Firefly III sensor platform.""" + coordinator = entry.runtime_data + entities: list[SensorEntity] = [ + FireflyAccountEntity( + coordinator=coordinator, + entity_description=description, + account=account, + ) + for account in coordinator.data.accounts + for description in ACCOUNT_SENSORS + ] + + entities.extend( + FireflyCategoryEntity( + coordinator=coordinator, + entity_description=description, + category=category, + ) + for category in coordinator.data.category_details + for description in CATEGORY_SENSORS + ) + + async_add_entities(entities) + + +class FireflyAccountEntity(FireflyBaseEntity, SensorEntity): + """Entity for Firefly III account.""" + + def __init__( + self, + coordinator: FireflyDataUpdateCoordinator, + entity_description: SensorEntityDescription, + account: Account, + ) -> None: + """Initialize Firefly account entity.""" + super().__init__(coordinator, entity_description) + self._account = account + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{account.id}" + self._attr_name = account.attributes.name + self._attr_native_unit_of_measurement = ( + coordinator.data.primary_currency.attributes.code + ) + + # Account type state doesn't go well with the icons.json. Need to fix it. + if account.attributes.type == "expense": + self._attr_icon = "mdi:cash-minus" + elif account.attributes.type == "asset": + self._attr_icon = "mdi:account-cash" + elif account.attributes.type == "revenue": + self._attr_icon = "mdi:cash-plus" + elif account.attributes.type == "liability": + self._attr_icon = "mdi:hand-coin" + else: + self._attr_icon = "mdi:bank" + + @property + def native_value(self) -> str | None: + """Return the state of the sensor.""" + return self._account.attributes.current_balance + + @property + def extra_state_attributes(self) -> dict[str, str] | None: + """Return extra state attributes for the account entity.""" + return { + "account_role": self._account.attributes.account_role or "", + "account_type": self._account.attributes.type or "", + "current_balance": str(self._account.attributes.current_balance or ""), + } + + +class FireflyCategoryEntity(FireflyBaseEntity, SensorEntity): + """Entity for Firefly III category.""" + + def __init__( + self, + coordinator: FireflyDataUpdateCoordinator, + entity_description: SensorEntityDescription, + category: Category, + ) -> None: + """Initialize Firefly category entity.""" + super().__init__(coordinator, entity_description) + self._category = category + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{category.id}" + self._attr_name = category.attributes.name + self._attr_native_unit_of_measurement = ( + coordinator.data.primary_currency.attributes.code + ) + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + spent_items = self._category.attributes.spent or [] + earned_items = self._category.attributes.earned or [] + + spent = sum(float(item.sum) for item in spent_items if item.sum is not None) + earned = sum(float(item.sum) for item in earned_items if item.sum is not None) + + if spent == 0 and earned == 0: + return None + return spent + earned diff --git a/homeassistant/components/firefly_iii/strings.json b/homeassistant/components/firefly_iii/strings.json new file mode 100644 index 00000000000000..14fc692b7baa98 --- /dev/null +++ b/homeassistant/components/firefly_iii/strings.json @@ -0,0 +1,39 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "api_key": "[%key:common::config_flow::data::api_key%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "url": "[%key:common::config_flow::data::url%]", + "api_key": "The API key for authenticating with Firefly", + "verify_ssl": "Verify the SSL certificate of the Firefly instance" + }, + "description": "You can create an API key in the Firefly UI. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)." + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "exceptions": { + "cannot_connect": { + "message": "An error occurred while trying to connect to the Firefly instance: {error}" + }, + "invalid_auth": { + "message": "An error occurred while trying to authenticate: {error}" + }, + "timeout_connect": { + "message": "A timeout occurred while trying to connect to the Firefly instance: {error}" + } + } +} diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index d565d2f7b5f4ac..efdab3122f5448 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -39,6 +39,7 @@ SENSOR_TYPE_PAUSED_TORRENTS = "paused_torrents" SENSOR_TYPE_ACTIVE_TORRENTS = "active_torrents" SENSOR_TYPE_INACTIVE_TORRENTS = "inactive_torrents" +SENSOR_TYPE_ERRORED_TORRENTS = "errored_torrents" def get_state(coordinator: QBittorrentDataCoordinator) -> str: @@ -221,6 +222,13 @@ class QBittorrentSensorEntityDescription(SensorEntityDescription): coordinator, ["stoppedDL", "stoppedUP"] ), ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_ERRORED_TORRENTS, + translation_key="errored_torrents", + value_fn=lambda coordinator: count_torrents_in_states( + coordinator, ["error", "missingFiles"] + ), + ), ) diff --git a/homeassistant/components/qbittorrent/services.yaml b/homeassistant/components/qbittorrent/services.yaml index f7fc6b95f64569..fc94e80f3581f9 100644 --- a/homeassistant/components/qbittorrent/services.yaml +++ b/homeassistant/components/qbittorrent/services.yaml @@ -18,6 +18,7 @@ get_torrents: - "all" - "seeding" - "started" + - "errored" get_all_torrents: fields: torrent_filter: @@ -33,3 +34,4 @@ get_all_torrents: - "all" - "seeding" - "started" + - "errored" diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index ef2f45bbc28be9..d392e081b71299 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -70,6 +70,10 @@ "name": "Paused torrents", "unit_of_measurement": "[%key:component::qbittorrent::entity::sensor::active_torrents::unit_of_measurement%]" }, + "errored_torrents": { + "name": "Errored torrents", + "unit_of_measurement": "[%key:component::qbittorrent::entity::sensor::active_torrents::unit_of_measurement%]" + }, "all_torrents": { "name": "All torrents", "unit_of_measurement": "[%key:component::qbittorrent::entity::sensor::active_torrents::unit_of_measurement%]" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f9e50f9a26ce56..1d2c6fc21a70d0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -201,6 +201,7 @@ "fibaro", "file", "filesize", + "firefly_iii", "fireservicerota", "fitbit", "fivem", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b0ef2400f04e8d..71c3ee23c81d99 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1984,6 +1984,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "firefly_iii": { + "name": "Firefly III", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "fireservicerota": { "name": "FireServiceRota", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index dcf71efe8982e9..c05ec7019b2a9b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1786,6 +1786,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.firefly_iii.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.fitbit.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index d66bbdfb116f06..8b5077eba58588 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2020,6 +2020,9 @@ pyfibaro==0.8.3 # homeassistant.components.fido pyfido==2.1.2 +# homeassistant.components.firefly_iii +pyfirefly==0.1.5 + # homeassistant.components.fireservicerota pyfireservicerota==0.0.46 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 16397e62653837..dfe52fcae5acf9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1689,6 +1689,9 @@ pyfibaro==0.8.3 # homeassistant.components.fido pyfido==2.1.2 +# homeassistant.components.firefly_iii +pyfirefly==0.1.5 + # homeassistant.components.fireservicerota pyfireservicerota==0.0.46 diff --git a/tests/components/firefly_iii/__init__.py b/tests/components/firefly_iii/__init__.py new file mode 100644 index 00000000000000..7ae33ed0ce05ff --- /dev/null +++ b/tests/components/firefly_iii/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Firefly III integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/firefly_iii/conftest.py b/tests/components/firefly_iii/conftest.py new file mode 100644 index 00000000000000..18250624ca7383 --- /dev/null +++ b/tests/components/firefly_iii/conftest.py @@ -0,0 +1,95 @@ +"""Common fixtures for the Firefly III tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from pyfirefly.models import About, Account, Bill, Budget, Category, Currency +import pytest + +from homeassistant.components.firefly_iii.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_value_fixture, +) + +MOCK_TEST_CONFIG = { + CONF_URL: "https://127.0.0.1:8080/", + CONF_API_KEY: "test_api_key", + CONF_VERIFY_SSL: True, +} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.firefly_iii.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_firefly_client() -> Generator[AsyncMock]: + """Mock Firefly client with dynamic exception injection support.""" + with ( + patch( + "homeassistant.components.firefly_iii.config_flow.Firefly" + ) as mock_client, + patch( + "homeassistant.components.firefly_iii.coordinator.Firefly", new=mock_client + ), + ): + client = mock_client.return_value + + client.get_about = AsyncMock( + return_value=About.from_dict(load_json_value_fixture("about.json", DOMAIN)) + ) + client.get_accounts = AsyncMock( + return_value=[ + Account.from_dict(account) + for account in load_json_array_fixture("accounts.json", DOMAIN) + ] + ) + client.get_categories = AsyncMock( + return_value=[ + Category.from_dict(category) + for category in load_json_array_fixture("categories.json", DOMAIN) + ] + ) + client.get_category = AsyncMock( + return_value=Category.from_dict( + load_json_value_fixture("category.json", DOMAIN) + ) + ) + client.get_currency_primary = AsyncMock( + return_value=Currency.from_dict( + load_json_value_fixture("primary_currency.json", DOMAIN) + ) + ) + client.get_budgets = AsyncMock( + return_value=[ + Budget.from_dict(budget) + for budget in load_json_array_fixture("budgets.json", DOMAIN) + ] + ) + client.get_bills = AsyncMock( + return_value=[ + Bill.from_dict(bill) + for bill in load_json_array_fixture("bills.json", DOMAIN) + ] + ) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Firefly III test", + data=MOCK_TEST_CONFIG, + entry_id="firefly_iii_test_entry_123", + ) diff --git a/tests/components/firefly_iii/fixtures/about.json b/tests/components/firefly_iii/fixtures/about.json new file mode 100644 index 00000000000000..4d15af129df78b --- /dev/null +++ b/tests/components/firefly_iii/fixtures/about.json @@ -0,0 +1,7 @@ +{ + "version": "5.8.0-alpha.1", + "api_version": "5.8.0-alpha.1", + "php_version": "8.1.5", + "os": "Linux", + "driver": "mysql" +} diff --git a/tests/components/firefly_iii/fixtures/accounts.json b/tests/components/firefly_iii/fixtures/accounts.json new file mode 100644 index 00000000000000..39c1f671f1e5ef --- /dev/null +++ b/tests/components/firefly_iii/fixtures/accounts.json @@ -0,0 +1,178 @@ +[ + { + "type": "accounts", + "id": "2", + "attributes": { + "created_at": "2018-09-17T12:46:47+01:00", + "updated_at": "2018-09-17T12:46:47+01:00", + "active": false, + "order": 1, + "name": "My checking account", + "type": "asset", + "account_role": "defaultAsset", + "currency_id": "12", + "currency_code": "EUR", + "currency_symbol": "$", + "currency_decimal_places": 2, + "native_currency_id": "12", + "native_currency_code": "EUR", + "native_currency_symbol": "$", + "native_currency_decimal_places": 2, + "current_balance": "123.45", + "native_current_balance": "123.45", + "current_balance_date": "2018-09-17T12:46:47+01:00", + "notes": "Some example notes", + "monthly_payment_date": "2018-09-17T12:46:47+01:00", + "credit_card_type": "monthlyFull", + "account_number": "7009312345678", + "iban": "GB98MIDL07009312345678", + "bic": "BOFAUS3N", + "virtual_balance": "123.45", + "native_virtual_balance": "123.45", + "opening_balance": "-1012.12", + "native_opening_balance": "-1012.12", + "opening_balance_date": "2018-09-17T12:46:47+01:00", + "liability_type": "loan", + "liability_direction": "credit", + "interest": "5.3", + "interest_period": "monthly", + "current_debt": "1012.12", + "include_net_worth": true, + "longitude": 5.916667, + "latitude": 51.983333, + "zoom_level": 6 + } + }, + { + "type": "accounts", + "id": "3", + "attributes": { + "created_at": "2019-01-01T10:00:00+01:00", + "updated_at": "2020-01-01T10:00:00+01:00", + "active": true, + "order": 2, + "name": "Savings Account", + "type": "expense", + "account_role": "savingsAsset", + "currency_id": "13", + "currency_code": "USD", + "currency_symbol": "$", + "currency_decimal_places": 2, + "native_currency_id": "13", + "native_currency_code": "USD", + "native_currency_symbol": "$", + "native_currency_decimal_places": 2, + "current_balance": "5000.00", + "native_current_balance": "5000.00", + "current_balance_date": "2020-01-01T10:00:00+01:00", + "notes": "Main savings account", + "monthly_payment_date": null, + "credit_card_type": null, + "account_number": "1234567890", + "iban": "US12345678901234567890", + "bic": "CITIUS33", + "virtual_balance": "0.00", + "native_virtual_balance": "0.00", + "opening_balance": "1000.00", + "native_opening_balance": "1000.00", + "opening_balance_date": "2019-01-01T10:00:00+01:00", + "liability_type": null, + "liability_direction": null, + "interest": "1.2", + "interest_period": "yearly", + "current_debt": null, + "include_net_worth": true, + "longitude": -74.006, + "latitude": 40.7128, + "zoom_level": 8 + } + }, + { + "type": "accounts", + "id": "4", + "attributes": { + "created_at": "2021-05-10T09:30:00+01:00", + "updated_at": "2022-05-10T09:30:00+01:00", + "active": true, + "order": 3, + "name": "Credit Card", + "type": "liability", + "account_role": "creditCard", + "currency_id": "14", + "currency_code": "GBP", + "currency_symbol": "£", + "currency_decimal_places": 2, + "native_currency_id": "14", + "native_currency_code": "GBP", + "native_currency_symbol": "£", + "native_currency_decimal_places": 2, + "current_balance": "-250.00", + "native_current_balance": "-250.00", + "current_balance_date": "2022-05-10T09:30:00+01:00", + "notes": "Credit card account", + "monthly_payment_date": "2022-05-15T09:30:00+01:00", + "credit_card_type": "monthlyFull", + "account_number": "9876543210", + "iban": "GB29NWBK60161331926819", + "bic": "NWBKGB2L", + "virtual_balance": "0.00", + "native_virtual_balance": "0.00", + "opening_balance": "0.00", + "native_opening_balance": "0.00", + "opening_balance_date": "2021-05-10T09:30:00+01:00", + "liability_type": "credit", + "liability_direction": "debit", + "interest": "19.99", + "interest_period": "monthly", + "current_debt": "250.00", + "include_net_worth": false, + "longitude": 0.1278, + "latitude": 51.5074, + "zoom_level": 10 + } + }, + { + "type": "accounts", + "id": "4", + "attributes": { + "created_at": "2021-05-10T09:30:00+01:00", + "updated_at": "2022-05-10T09:30:00+01:00", + "active": true, + "order": 3, + "name": "Credit Card", + "type": "revenue", + "account_role": "creditCard", + "currency_id": "14", + "currency_code": "GBP", + "currency_symbol": "£", + "currency_decimal_places": 2, + "native_currency_id": "14", + "native_currency_code": "GBP", + "native_currency_symbol": "£", + "native_currency_decimal_places": 2, + "current_balance": "-250.00", + "native_current_balance": "-250.00", + "current_balance_date": "2022-05-10T09:30:00+01:00", + "notes": "Credit card account", + "monthly_payment_date": "2022-05-15T09:30:00+01:00", + "credit_card_type": "monthlyFull", + "account_number": "9876543210", + "iban": "GB29NWBK60161331926819", + "bic": "NWBKGB2L", + "virtual_balance": "0.00", + "native_virtual_balance": "0.00", + "opening_balance": "0.00", + "native_opening_balance": "0.00", + "opening_balance_date": "2021-05-10T09:30:00+01:00", + "liability_type": "credit", + "liability_direction": "debit", + "interest": "19.99", + "interest_period": "monthly", + "current_debt": "250.00", + "include_net_worth": false, + "longitude": 0.1278, + "latitude": 51.5074, + "zoom_level": 10 + } + } +] diff --git a/tests/components/firefly_iii/fixtures/bills.json b/tests/components/firefly_iii/fixtures/bills.json new file mode 100644 index 00000000000000..a59ee410581e89 --- /dev/null +++ b/tests/components/firefly_iii/fixtures/bills.json @@ -0,0 +1,44 @@ +[ + { + "type": "bills", + "id": "2", + "attributes": { + "created_at": "2018-09-17T12:46:47+01:00", + "updated_at": "2018-09-17T12:46:47+01:00", + "currency_id": "5", + "currency_code": "EUR", + "currency_symbol": "$", + "currency_decimal_places": 2, + "native_currency_id": "5", + "native_currency_code": "EUR", + "native_currency_symbol": "$", + "native_currency_decimal_places": 2, + "name": "Rent", + "amount_min": "123.45", + "amount_max": "123.45", + "native_amount_min": "123.45", + "native_amount_max": "123.45", + "date": "2018-09-17T12:46:47+01:00", + "end_date": "2018-09-17T12:46:47+01:00", + "extension_date": "2018-09-17T12:46:47+01:00", + "repeat_freq": "monthly", + "skip": 0, + "active": true, + "order": 1, + "notes": "Some example notes", + "next_expected_match": "2018-09-17T12:46:47+01:00", + "next_expected_match_diff": "today", + "object_group_id": "5", + "object_group_order": 5, + "object_group_title": "Example Group", + "pay_dates": ["2018-09-17T12:46:47+01:00"], + "paid_dates": [ + { + "transaction_group_id": "123", + "transaction_journal_id": "123", + "date": "2018-09-17T12:46:47+01:00" + } + ] + } + } +] diff --git a/tests/components/firefly_iii/fixtures/budgets.json b/tests/components/firefly_iii/fixtures/budgets.json new file mode 100644 index 00000000000000..39bd152e9582ea --- /dev/null +++ b/tests/components/firefly_iii/fixtures/budgets.json @@ -0,0 +1,35 @@ +[ + { + "type": "budgets", + "id": "2", + "attributes": { + "created_at": "2018-09-17T12:46:47+01:00", + "updated_at": "2018-09-17T12:46:47+01:00", + "name": "Bills", + "active": false, + "notes": "Some notes", + "order": 5, + "auto_budget_type": "reset", + "currency_id": "12", + "currency_code": "EUR", + "currency_symbol": "$", + "currency_decimal_places": 2, + "native_currency_id": "5", + "native_currency_code": "EUR", + "native_currency_symbol": "$", + "native_currency_decimal_places": 2, + "auto_budget_amount": "-1012.12", + "native_auto_budget_amount": "-1012.12", + "auto_budget_period": "monthly", + "spent": [ + { + "sum": "123.45", + "currency_id": "5", + "currency_code": "USD", + "currency_symbol": "$", + "currency_decimal_places": 2 + } + ] + } + } +] diff --git a/tests/components/firefly_iii/fixtures/categories.json b/tests/components/firefly_iii/fixtures/categories.json new file mode 100644 index 00000000000000..ee7c7df2f58210 --- /dev/null +++ b/tests/components/firefly_iii/fixtures/categories.json @@ -0,0 +1,34 @@ +[ + { + "type": "categories", + "id": "2", + "attributes": { + "created_at": "2018-09-17T12:46:47+01:00", + "updated_at": "2018-09-17T12:46:47+01:00", + "name": "Lunch", + "notes": "Some example notes", + "native_currency_id": "5", + "native_currency_code": "EUR", + "native_currency_symbol": "$", + "native_currency_decimal_places": 2, + "spent": [ + { + "currency_id": "5", + "currency_code": "USD", + "currency_symbol": "$", + "currency_decimal_places": 2, + "sum": "-12423.45" + } + ], + "earned": [ + { + "currency_id": "5", + "currency_code": "USD", + "currency_symbol": "$", + "currency_decimal_places": 2, + "sum": "123.45" + } + ] + } + } +] diff --git a/tests/components/firefly_iii/fixtures/category.json b/tests/components/firefly_iii/fixtures/category.json new file mode 100644 index 00000000000000..415edb6ef0a1ec --- /dev/null +++ b/tests/components/firefly_iii/fixtures/category.json @@ -0,0 +1,32 @@ +{ + "type": "categories", + "id": "2", + "attributes": { + "created_at": "2018-09-17T12:46:47+01:00", + "updated_at": "2018-09-17T12:46:47+01:00", + "name": "Lunch", + "notes": "Some example notes", + "native_currency_id": "5", + "native_currency_code": "EUR", + "native_currency_symbol": "$", + "native_currency_decimal_places": 2, + "spent": [ + { + "currency_id": "5", + "currency_code": "USD", + "currency_symbol": "$", + "currency_decimal_places": 2, + "sum": "-12423.45" + } + ], + "earned": [ + { + "currency_id": "5", + "currency_code": "USD", + "currency_symbol": "$", + "currency_decimal_places": 2, + "sum": "123.45" + } + ] + } +} diff --git a/tests/components/firefly_iii/fixtures/primary_currency.json b/tests/components/firefly_iii/fixtures/primary_currency.json new file mode 100644 index 00000000000000..38472f84c55dc2 --- /dev/null +++ b/tests/components/firefly_iii/fixtures/primary_currency.json @@ -0,0 +1,15 @@ +{ + "type": "currencies", + "id": "2", + "attributes": { + "created_at": "2018-09-17T12:46:47+01:00", + "updated_at": "2018-09-17T12:46:47+01:00", + "enabled": true, + "default": false, + "native": false, + "code": "AMS", + "name": "Ankh-Morpork dollar", + "symbol": "AM$", + "decimal_places": 2 + } +} diff --git a/tests/components/firefly_iii/snapshots/test_sensor.ambr b/tests/components/firefly_iii/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..d381462e65a876 --- /dev/null +++ b/tests/components/firefly_iii/snapshots/test_sensor.ambr @@ -0,0 +1,225 @@ +# serializer version: 1 +# name: test_all_entities[sensor.firefly_iii_test_credit_card-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.firefly_iii_test_credit_card', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:hand-coin', + 'original_name': 'Credit Card', + 'platform': 'firefly_iii', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'account', + 'unique_id': 'None_account_type_4', + 'unit_of_measurement': 'AMS', + }) +# --- +# name: test_all_entities[sensor.firefly_iii_test_credit_card-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'account_role': 'creditCard', + 'account_type': 'liability', + 'current_balance': '-250.00', + 'device_class': 'monetary', + 'friendly_name': 'Firefly III test Credit Card', + 'icon': 'mdi:hand-coin', + 'state_class': , + 'unit_of_measurement': 'AMS', + }), + 'context': , + 'entity_id': 'sensor.firefly_iii_test_credit_card', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-250.00', + }) +# --- +# name: test_all_entities[sensor.firefly_iii_test_lunch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.firefly_iii_test_lunch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lunch', + 'platform': 'firefly_iii', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'category', + 'unique_id': 'None_category_2', + 'unit_of_measurement': 'AMS', + }) +# --- +# name: test_all_entities[sensor.firefly_iii_test_lunch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Firefly III test Lunch', + 'state_class': , + 'unit_of_measurement': 'AMS', + }), + 'context': , + 'entity_id': 'sensor.firefly_iii_test_lunch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-12300.0', + }) +# --- +# name: test_all_entities[sensor.firefly_iii_test_my_checking_account-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.firefly_iii_test_my_checking_account', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:account-cash', + 'original_name': 'My checking account', + 'platform': 'firefly_iii', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'account', + 'unique_id': 'None_account_type_2', + 'unit_of_measurement': 'AMS', + }) +# --- +# name: test_all_entities[sensor.firefly_iii_test_my_checking_account-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'account_role': 'defaultAsset', + 'account_type': 'asset', + 'current_balance': '123.45', + 'device_class': 'monetary', + 'friendly_name': 'Firefly III test My checking account', + 'icon': 'mdi:account-cash', + 'state_class': , + 'unit_of_measurement': 'AMS', + }), + 'context': , + 'entity_id': 'sensor.firefly_iii_test_my_checking_account', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.45', + }) +# --- +# name: test_all_entities[sensor.firefly_iii_test_savings_account-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.firefly_iii_test_savings_account', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:cash-minus', + 'original_name': 'Savings Account', + 'platform': 'firefly_iii', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'account', + 'unique_id': 'None_account_type_3', + 'unit_of_measurement': 'AMS', + }) +# --- +# name: test_all_entities[sensor.firefly_iii_test_savings_account-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'account_role': 'savingsAsset', + 'account_type': 'expense', + 'current_balance': '5000.00', + 'device_class': 'monetary', + 'friendly_name': 'Firefly III test Savings Account', + 'icon': 'mdi:cash-minus', + 'state_class': , + 'unit_of_measurement': 'AMS', + }), + 'context': , + 'entity_id': 'sensor.firefly_iii_test_savings_account', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5000.00', + }) +# --- diff --git a/tests/components/firefly_iii/test_config_flow.py b/tests/components/firefly_iii/test_config_flow.py new file mode 100644 index 00000000000000..99474ddccc3011 --- /dev/null +++ b/tests/components/firefly_iii/test_config_flow.py @@ -0,0 +1,134 @@ +"""Test the Firefly III config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from pyfirefly.exceptions import ( + FireflyAuthenticationError, + FireflyConnectionError, + FireflyTimeoutError, +) +import pytest + +from homeassistant.components.firefly_iii.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import MOCK_TEST_CONFIG + +from tests.common import MockConfigEntry + +MOCK_USER_SETUP = { + CONF_URL: "https://127.0.0.1:8080/", + CONF_API_KEY: "test_api_key", + CONF_VERIFY_SSL: True, +} + + +async def test_form_and_flow( + hass: HomeAssistant, + mock_firefly_client: MagicMock, + mock_setup_entry: MagicMock, +) -> None: + """Test we get the form and can complete the flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_SETUP, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "https://127.0.0.1:8080/" + assert result["data"] == MOCK_TEST_CONFIG + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + ( + FireflyAuthenticationError, + "invalid_auth", + ), + ( + FireflyConnectionError, + "cannot_connect", + ), + ( + FireflyTimeoutError, + "timeout_connect", + ), + ( + Exception("Some other error"), + "unknown", + ), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + mock_firefly_client: AsyncMock, + mock_setup_entry: MagicMock, + exception: Exception, + reason: str, +) -> None: + """Test we handle all exceptions.""" + mock_firefly_client.get_about.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_SETUP, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": reason} + + mock_firefly_client.get_about.side_effect = None + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_SETUP, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "https://127.0.0.1:8080/" + assert result["data"] == MOCK_TEST_CONFIG + + +async def test_duplicate_entry( + hass: HomeAssistant, + mock_firefly_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we handle duplicate entries.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_SETUP, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/firefly_iii/test_sensor.py b/tests/components/firefly_iii/test_sensor.py new file mode 100644 index 00000000000000..9a26db29d18f82 --- /dev/null +++ b/tests/components/firefly_iii/test_sensor.py @@ -0,0 +1,31 @@ +"""Tests for the Firefly III sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_firefly_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.firefly_iii._PLATFORMS", + [Platform.SENSOR], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) diff --git a/tests/components/qbittorrent/conftest.py b/tests/components/qbittorrent/conftest.py index 17fb8e15b478a8..4bc8a7b899c34e 100644 --- a/tests/components/qbittorrent/conftest.py +++ b/tests/components/qbittorrent/conftest.py @@ -6,6 +6,11 @@ import pytest import requests_mock +from homeassistant.components.qbittorrent import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL + +from tests.common import MockConfigEntry, load_json_object_fixture + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -24,3 +29,31 @@ def mock_api() -> Generator[requests_mock.Mocker]: mocker.get("http://localhost:8080/api/v2/transfer/speedLimitsMode") mocker.post("http://localhost:8080/api/v2/auth/login", text="Ok.") yield mocker + + +@pytest.fixture +def mock_qbittorrent() -> Generator[AsyncMock]: + """Mock qbittorrent client.""" + with patch( + "homeassistant.components.qbittorrent.helpers.Client", autospec=True + ) as mock_client: + client = mock_client.return_value + client.sync_maindata.return_value = load_json_object_fixture( + "sync_maindata.json", DOMAIN + ) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create a mock config entry for qbittorrent.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "admin", + CONF_PASSWORD: "adminadmin", + CONF_VERIFY_SSL: False, + }, + entry_id="01K6E7464PTQKDE24VAJQZPTH2", + ) diff --git a/tests/components/qbittorrent/fixtures/sync_maindata.json b/tests/components/qbittorrent/fixtures/sync_maindata.json new file mode 100644 index 00000000000000..6a7e74c0bf0870 --- /dev/null +++ b/tests/components/qbittorrent/fixtures/sync_maindata.json @@ -0,0 +1,392 @@ +{ + "categories": { + "radarr": { + "name": "radarr", + "savePath": "" + } + }, + "full_update": true, + "rid": 2, + "server_state": { + "alltime_dl": 861098349149, + "alltime_ul": 724759510499, + "average_time_queue": 27, + "connection_status": "connected", + "dht_nodes": 370, + "dl_info_data": 127006787927, + "dl_info_speed": 0, + "dl_rate_limit": 0, + "free_space_on_disk": 87547486208, + "global_ratio": "0.84", + "last_external_address_v4": "1.1.1.1", + "last_external_address_v6": "", + "queued_io_jobs": 0, + "queueing": true, + "read_cache_hits": "0", + "read_cache_overload": "0", + "refresh_interval": 1500, + "total_buffers_size": 0, + "total_peer_connections": 2, + "total_queued_size": 0, + "total_wasted_session": 374937191, + "up_info_data": 119803126285, + "up_info_speed": 0, + "up_rate_limit": 0, + "use_alt_speed_limits": false, + "use_subcategories": false, + "write_cache_overload": "0" + }, + "torrents": { + "cb48f83c1f1f7bb781e9c4af1602a14df7a915fe": { + "added_on": 1751736848, + "amount_left": 89178112, + "auto_tmm": false, + "availability": 0.6449999809265137, + "category": "radarr", + "comment": "", + "completed": 167913804, + "completion_on": -1, + "content_path": "/download_mnt/incomplete/ubuntu", + "dl_limit": 0, + "dlspeed": 0, + "download_path": "/download_mnt/incomplete", + "downloaded": 167941419, + "downloaded_session": 0, + "eta": 8640000, + "f_l_piece_prio": false, + "force_start": false, + "has_metadata": true, + "inactive_seeding_time_limit": -2, + "infohash_v1": "asdasdasdasd", + "infohash_v2": "", + "last_activity": 1751896934, + "magnet_uri": "magnet:?xt=urn:btih:ubuntu", + "max_inactive_seeding_time": -1, + "max_ratio": 1, + "max_seeding_time": 120, + "name": "ubuntu", + "num_complete": 0, + "num_incomplete": 2, + "num_leechs": 0, + "num_seeds": 0, + "popularity": 0, + "priority": 1, + "private": false, + "progress": 0.6531275141300048, + "ratio": 0, + "ratio_limit": -2, + "reannounce": 79, + "root_path": "/download_mnt/incomplete/ubuntu", + "save_path": "/download_mnt/complete", + "seeding_time": 0, + "seeding_time_limit": -2, + "seen_complete": 1756425055, + "seq_dl": false, + "size": 257091916, + "state": "stalledDL", + "super_seeding": false, + "tags": "", + "time_active": 7528849, + "total_size": 257091916, + "tracker": "http://tracker.ubuntu.com:2710/announce", + "trackers_count": 34, + "up_limit": 0, + "uploaded": 0, + "uploaded_session": 0, + "upspeed": 0 + }, + "cb48f83c1f1f7bb781e9c4af1602a14df7a915fb": { + "added_on": 1751736848, + "amount_left": 89178112, + "auto_tmm": false, + "availability": 0.6449999809265137, + "category": "radarr", + "comment": "", + "completed": 167913804, + "completion_on": -1, + "content_path": "/download_mnt/incomplete/ubuntu", + "dl_limit": 0, + "dlspeed": 0, + "download_path": "/download_mnt/incomplete", + "downloaded": 167941419, + "downloaded_session": 0, + "eta": 8640000, + "f_l_piece_prio": false, + "force_start": false, + "has_metadata": true, + "inactive_seeding_time_limit": -2, + "infohash_v1": "asdasdasdasd", + "infohash_v2": "", + "last_activity": 1751896934, + "magnet_uri": "magnet:?xt=urn:btih:ubuntu", + "max_inactive_seeding_time": -1, + "max_ratio": 1, + "max_seeding_time": 120, + "name": "ubuntu", + "num_complete": 0, + "num_incomplete": 2, + "num_leechs": 0, + "num_seeds": 0, + "popularity": 0, + "priority": 1, + "private": false, + "progress": 0.6531275141300048, + "ratio": 0, + "ratio_limit": -2, + "reannounce": 79, + "root_path": "/download_mnt/incomplete/ubuntu", + "save_path": "/download_mnt/complete", + "seeding_time": 0, + "seeding_time_limit": -2, + "seen_complete": 1756425055, + "seq_dl": false, + "size": 257091916, + "state": "stoppedDL", + "super_seeding": false, + "tags": "", + "time_active": 7528849, + "total_size": 257091916, + "tracker": "http://tracker.ubuntu.com:2710/announce", + "trackers_count": 34, + "up_limit": 0, + "uploaded": 0, + "uploaded_session": 0, + "upspeed": 0 + }, + "cb48f83c1f1f7bb781e9c4af1602a14df7a915fc": { + "added_on": 1751736848, + "amount_left": 89178112, + "auto_tmm": false, + "availability": 0.6449999809265137, + "category": "radarr", + "comment": "", + "completed": 167913804, + "completion_on": -1, + "content_path": "/download_mnt/incomplete/ubuntu", + "dl_limit": 0, + "dlspeed": 0, + "download_path": "/download_mnt/incomplete", + "downloaded": 167941419, + "downloaded_session": 0, + "eta": 8640000, + "f_l_piece_prio": false, + "force_start": false, + "has_metadata": true, + "inactive_seeding_time_limit": -2, + "infohash_v1": "asdasdasdasd", + "infohash_v2": "", + "last_activity": 1751896934, + "magnet_uri": "magnet:?xt=urn:btih:ubuntu", + "max_inactive_seeding_time": -1, + "max_ratio": 1, + "max_seeding_time": 120, + "name": "ubuntu", + "num_complete": 0, + "num_incomplete": 2, + "num_leechs": 0, + "num_seeds": 0, + "popularity": 0, + "priority": 1, + "private": false, + "progress": 0.6531275141300048, + "ratio": 0, + "ratio_limit": -2, + "reannounce": 79, + "root_path": "/download_mnt/incomplete/ubuntu", + "save_path": "/download_mnt/complete", + "seeding_time": 0, + "seeding_time_limit": -2, + "seen_complete": 1756425055, + "seq_dl": false, + "size": 257091916, + "state": "stalledUP", + "super_seeding": false, + "tags": "", + "time_active": 7528849, + "total_size": 257091916, + "tracker": "http://tracker.ubuntu.com:2710/announce", + "trackers_count": 34, + "up_limit": 0, + "uploaded": 0, + "uploaded_session": 0, + "upspeed": 0 + }, + "cb48f83c1f1f7bb781e9c4af1602a14df7a915fd": { + "added_on": 1751736848, + "amount_left": 89178112, + "auto_tmm": false, + "availability": 0.6449999809265137, + "category": "radarr", + "comment": "", + "completed": 167913804, + "completion_on": -1, + "content_path": "/download_mnt/incomplete/ubuntu", + "dl_limit": 0, + "dlspeed": 0, + "download_path": "/download_mnt/incomplete", + "downloaded": 167941419, + "downloaded_session": 0, + "eta": 8640000, + "f_l_piece_prio": false, + "force_start": false, + "has_metadata": true, + "inactive_seeding_time_limit": -2, + "infohash_v1": "asdasdasdasd", + "infohash_v2": "", + "last_activity": 1751896934, + "magnet_uri": "magnet:?xt=urn:btih:ubuntu", + "max_inactive_seeding_time": -1, + "max_ratio": 1, + "max_seeding_time": 120, + "name": "ubuntu", + "num_complete": 0, + "num_incomplete": 2, + "num_leechs": 0, + "num_seeds": 0, + "popularity": 0, + "priority": 1, + "private": false, + "progress": 0.6531275141300048, + "ratio": 0, + "ratio_limit": -2, + "reannounce": 79, + "root_path": "/download_mnt/incomplete/ubuntu", + "save_path": "/download_mnt/complete", + "seeding_time": 0, + "seeding_time_limit": -2, + "seen_complete": 1756425055, + "seq_dl": false, + "size": 257091916, + "state": "error", + "super_seeding": false, + "tags": "", + "time_active": 7528849, + "total_size": 257091916, + "tracker": "http://tracker.ubuntu.com:2710/announce", + "trackers_count": 34, + "up_limit": 0, + "uploaded": 0, + "uploaded_session": 0, + "upspeed": 0 + }, + "cb48f83c1f1f7bb781e9c4af1602a14df7a915ff": { + "added_on": 1751736848, + "amount_left": 89178112, + "auto_tmm": false, + "availability": 0.6449999809265137, + "category": "radarr", + "comment": "", + "completed": 167913804, + "completion_on": -1, + "content_path": "/download_mnt/incomplete/ubuntu", + "dl_limit": 0, + "dlspeed": 0, + "download_path": "/download_mnt/incomplete", + "downloaded": 167941419, + "downloaded_session": 0, + "eta": 8640000, + "f_l_piece_prio": false, + "force_start": false, + "has_metadata": true, + "inactive_seeding_time_limit": -2, + "infohash_v1": "asdasdasdasd", + "infohash_v2": "", + "last_activity": 1751896934, + "magnet_uri": "magnet:?xt=urn:btih:ubuntu", + "max_inactive_seeding_time": -1, + "max_ratio": 1, + "max_seeding_time": 120, + "name": "ubuntu", + "num_complete": 0, + "num_incomplete": 2, + "num_leechs": 0, + "num_seeds": 0, + "popularity": 0, + "priority": 1, + "private": false, + "progress": 0.6531275141300048, + "ratio": 0, + "ratio_limit": -2, + "reannounce": 79, + "root_path": "/download_mnt/incomplete/ubuntu", + "save_path": "/download_mnt/complete", + "seeding_time": 0, + "seeding_time_limit": -2, + "seen_complete": 1756425055, + "seq_dl": false, + "size": 257091916, + "state": "missingFiles", + "super_seeding": false, + "tags": "", + "time_active": 7528849, + "total_size": 257091916, + "tracker": "http://tracker.ubuntu.com:2710/announce", + "trackers_count": 34, + "up_limit": 0, + "uploaded": 0, + "uploaded_session": 0, + "upspeed": 0 + }, + "cb48f83c1f1f7bb781e9c4af1602a14df7a915fa": { + "added_on": 1751736848, + "amount_left": 89178112, + "auto_tmm": false, + "availability": 0.6449999809265137, + "category": "radarr", + "comment": "", + "completed": 167913804, + "completion_on": -1, + "content_path": "/download_mnt/incomplete/ubuntu", + "dl_limit": 0, + "dlspeed": 0, + "download_path": "/download_mnt/incomplete", + "downloaded": 167941419, + "downloaded_session": 0, + "eta": 8640000, + "f_l_piece_prio": false, + "force_start": false, + "has_metadata": true, + "inactive_seeding_time_limit": -2, + "infohash_v1": "asdasdasdasd", + "infohash_v2": "", + "last_activity": 1751896934, + "magnet_uri": "magnet:?xt=urn:btih:ubuntu", + "max_inactive_seeding_time": -1, + "max_ratio": 1, + "max_seeding_time": 120, + "name": "ubuntu", + "num_complete": 0, + "num_incomplete": 2, + "num_leechs": 0, + "num_seeds": 0, + "popularity": 0, + "priority": 1, + "private": false, + "progress": 0.6531275141300048, + "ratio": 0, + "ratio_limit": -2, + "reannounce": 79, + "root_path": "/download_mnt/incomplete/ubuntu", + "save_path": "/download_mnt/complete", + "seeding_time": 0, + "seeding_time_limit": -2, + "seen_complete": 1756425055, + "seq_dl": false, + "size": 257091916, + "state": "downloading", + "super_seeding": false, + "tags": "", + "time_active": 7528849, + "total_size": 257091916, + "tracker": "http://tracker.ubuntu.com:2710/announce", + "trackers_count": 34, + "up_limit": 0, + "uploaded": 0, + "uploaded_session": 0, + "upspeed": 0 + } + }, + "trackers": { + "http://tracker.ipv6tracker.org:80/announce": ["abc"] + } +} diff --git a/tests/components/qbittorrent/snapshots/test_sensor.ambr b/tests/components/qbittorrent/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..2f1cfe985ed4cf --- /dev/null +++ b/tests/components/qbittorrent/snapshots/test_sensor.ambr @@ -0,0 +1,773 @@ +# serializer version: 1 +# name: test_entities[sensor.mock_title_active_torrents-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_active_torrents', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Active torrents', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_torrents', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-active_torrents', + 'unit_of_measurement': 'torrents', + }) +# --- +# name: test_entities[sensor.mock_title_active_torrents-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Active torrents', + 'unit_of_measurement': 'torrents', + }), + 'context': , + 'entity_id': 'sensor.mock_title_active_torrents', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_entities[sensor.mock_title_all_time_download-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_all_time_download', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'All-time download', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alltime_download', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-alltime_download', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.mock_title_all_time_download-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Mock Title All-time download', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_all_time_download', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.783164386256431', + }) +# --- +# name: test_entities[sensor.mock_title_all_time_upload-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_all_time_upload', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'TiB', + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'All-time upload', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alltime_upload', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-alltime_upload', + 'unit_of_measurement': 'TiB', + }) +# --- +# name: test_entities[sensor.mock_title_all_time_upload-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Mock Title All-time upload', + 'state_class': , + 'unit_of_measurement': 'TiB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_all_time_upload', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.659164934858381', + }) +# --- +# name: test_entities[sensor.mock_title_all_torrents-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_all_torrents', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'All torrents', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'all_torrents', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-all_torrents', + 'unit_of_measurement': 'torrents', + }) +# --- +# name: test_entities[sensor.mock_title_all_torrents-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title All torrents', + 'unit_of_measurement': 'torrents', + }), + 'context': , + 'entity_id': 'sensor.mock_title_all_torrents', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_entities[sensor.mock_title_connection_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'connected', + 'firewalled', + 'disconnected', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_connection_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connection status', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'connection_status', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-connection_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.mock_title_connection_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Title Connection status', + 'options': list([ + 'connected', + 'firewalled', + 'disconnected', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_title_connection_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'connected', + }) +# --- +# name: test_entities[sensor.mock_title_download_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_download_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Download speed', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'download_speed', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-download_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.mock_title_download_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Download speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_download_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_entities[sensor.mock_title_download_speed_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_download_speed_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Download speed limit', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'download_speed_limit', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-download_speed_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.mock_title_download_speed_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Download speed limit', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_download_speed_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_entities[sensor.mock_title_errored_torrents-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_errored_torrents', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Errored torrents', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'errored_torrents', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-errored_torrents', + 'unit_of_measurement': 'torrents', + }) +# --- +# name: test_entities[sensor.mock_title_errored_torrents-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Errored torrents', + 'unit_of_measurement': 'torrents', + }), + 'context': , + 'entity_id': 'sensor.mock_title_errored_torrents', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_entities[sensor.mock_title_global_ratio-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_global_ratio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Global ratio', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'global_ratio', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-global_ratio', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.mock_title_global_ratio-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Global ratio', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_global_ratio', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.84', + }) +# --- +# name: test_entities[sensor.mock_title_inactive_torrents-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_inactive_torrents', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Inactive torrents', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'inactive_torrents', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-inactive_torrents', + 'unit_of_measurement': 'torrents', + }) +# --- +# name: test_entities[sensor.mock_title_inactive_torrents-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Inactive torrents', + 'unit_of_measurement': 'torrents', + }), + 'context': , + 'entity_id': 'sensor.mock_title_inactive_torrents', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_entities[sensor.mock_title_paused_torrents-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_paused_torrents', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Paused torrents', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'paused_torrents', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-paused_torrents', + 'unit_of_measurement': 'torrents', + }) +# --- +# name: test_entities[sensor.mock_title_paused_torrents-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Paused torrents', + 'unit_of_measurement': 'torrents', + }), + 'context': , + 'entity_id': 'sensor.mock_title_paused_torrents', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_entities[sensor.mock_title_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'idle', + 'up_down', + 'seeding', + 'downloading', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_status', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-current_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.mock_title_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Title Status', + 'options': list([ + 'idle', + 'up_down', + 'seeding', + 'downloading', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_title_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_entities[sensor.mock_title_upload_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_upload_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upload speed', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'upload_speed', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-upload_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.mock_title_upload_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Upload speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_upload_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_entities[sensor.mock_title_upload_speed_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_upload_speed_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upload speed limit', + 'platform': 'qbittorrent', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'upload_speed_limit', + 'unique_id': '01K6E7464PTQKDE24VAJQZPTH2-upload_speed_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.mock_title_upload_speed_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Upload speed limit', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_upload_speed_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- diff --git a/tests/components/qbittorrent/test_sensor.py b/tests/components/qbittorrent/test_sensor.py new file mode 100644 index 00000000000000..e07df7988a84e7 --- /dev/null +++ b/tests/components/qbittorrent/test_sensor.py @@ -0,0 +1,29 @@ +"""Tests for the qBittorrent sensor platform, including errored torrents.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entities( + hass: HomeAssistant, + mock_qbittorrent: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that sensors are created.""" + with patch("homeassistant.components.qbittorrent.PLATFORMS", [Platform.SENSOR]): + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)