From 22091fb80337d0474ccebf47f1f69ca8149374ec Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 30 Sep 2026 07:53:43 +0200 Subject: [PATCH] Add new Vitesy integration (#181865) --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/vitesy/__init__.py | 25 ++ .../components/vitesy/config_flow.py | 77 ++++ homeassistant/components/vitesy/const.py | 8 + .../components/vitesy/coordinator.py | 81 ++++ homeassistant/components/vitesy/entity.py | 51 +++ homeassistant/components/vitesy/icons.json | 12 + homeassistant/components/vitesy/manifest.json | 12 + .../components/vitesy/quality_scale.yaml | 78 ++++ homeassistant/components/vitesy/sensor.py | 190 +++++++++ homeassistant/components/vitesy/strings.json | 53 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + tests/components/vitesy/__init__.py | 13 + tests/components/vitesy/conftest.py | 94 +++++ .../vitesy/snapshots/test_sensor.ambr | 384 ++++++++++++++++++ tests/components/vitesy/test_config_flow.py | 114 ++++++ tests/components/vitesy/test_init.py | 99 +++++ tests/components/vitesy/test_sensor.py | 154 +++++++ 22 files changed, 1468 insertions(+) create mode 100644 homeassistant/components/vitesy/__init__.py create mode 100644 homeassistant/components/vitesy/config_flow.py create mode 100644 homeassistant/components/vitesy/const.py create mode 100644 homeassistant/components/vitesy/coordinator.py create mode 100644 homeassistant/components/vitesy/entity.py create mode 100644 homeassistant/components/vitesy/icons.json create mode 100644 homeassistant/components/vitesy/manifest.json create mode 100644 homeassistant/components/vitesy/quality_scale.yaml create mode 100644 homeassistant/components/vitesy/sensor.py create mode 100644 homeassistant/components/vitesy/strings.json create mode 100644 tests/components/vitesy/__init__.py create mode 100644 tests/components/vitesy/conftest.py create mode 100644 tests/components/vitesy/snapshots/test_sensor.ambr create mode 100644 tests/components/vitesy/test_config_flow.py create mode 100644 tests/components/vitesy/test_init.py create mode 100644 tests/components/vitesy/test_sensor.py diff --git a/.strict-typing b/.strict-typing index fa035070a120..7fdccdc17dbc 100644 --- a/.strict-typing +++ b/.strict-typing @@ -648,6 +648,7 @@ homeassistant.components.velbus.* homeassistant.components.velux.* homeassistant.components.victron_gx.* homeassistant.components.vistapool.* +homeassistant.components.vitesy.* homeassistant.components.vivotek.* homeassistant.components.vizio.* homeassistant.components.vlc_telnet.* diff --git a/CODEOWNERS b/CODEOWNERS index 2752c77ff821..9517deba8732 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -2052,6 +2052,8 @@ CLAUDE.md @home-assistant/core /tests/components/vilfo/ @ManneW /homeassistant/components/vistapool/ @fdebrus /tests/components/vistapool/ @fdebrus +/homeassistant/components/vitesy/ @chemelli74 +/tests/components/vitesy/ @chemelli74 /homeassistant/components/vivotek/ @HarlemSquirrel /tests/components/vivotek/ @HarlemSquirrel /homeassistant/components/vizio/ @raman325 diff --git a/homeassistant/components/vitesy/__init__.py b/homeassistant/components/vitesy/__init__.py new file mode 100644 index 000000000000..18dc17866799 --- /dev/null +++ b/homeassistant/components/vitesy/__init__.py @@ -0,0 +1,25 @@ +"""The Vitesy integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import VitesyConfigEntry, VitesyDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: VitesyConfigEntry) -> bool: + """Set up Vitesy from a config entry.""" + coordinator = VitesyDataUpdateCoordinator(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: VitesyConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/vitesy/config_flow.py b/homeassistant/components/vitesy/config_flow.py new file mode 100644 index 000000000000..f4e1560d1cac --- /dev/null +++ b/homeassistant/components/vitesy/config_flow.py @@ -0,0 +1,77 @@ +"""Config flow for the Vitesy integration.""" + +from typing import Any, override + +from aiovitesy.api import VitesyApi +from aiovitesy.exceptions import CannotAuthenticate, VitesyError +import probatio + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +STEP_USER_DATA_SCHEMA = probatio.Schema( + { + probatio.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + autocomplete="username", + ) + ), + probatio.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } +) + + +async def _validate_credentials(hass: HomeAssistant, email: str, password: str) -> str: + """Authenticate and return the account's stable user id.""" + api = VitesyApi(email, password, async_get_clientsession(hass)) + await api.login() + user = await api.get_user() + user_id = user.get("id") + if not isinstance(user_id, (str, int)): + raise VitesyError(f"Profile response is missing a valid user id: {user_id!r}") + return str(user_id) + + +class VitesyConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Vitesy.""" + + @override + 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: + email = user_input[CONF_EMAIL] + + try: + user_id = await _validate_credentials( + self.hass, email, user_input[CONF_PASSWORD] + ) + except CannotAuthenticate: + errors["base"] = "invalid_auth" + except VitesyError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=email, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/vitesy/const.py b/homeassistant/components/vitesy/const.py new file mode 100644 index 000000000000..fd6ef2fe5353 --- /dev/null +++ b/homeassistant/components/vitesy/const.py @@ -0,0 +1,8 @@ +"""Constants for the Vitesy integration.""" + +import logging +from typing import Final + +DOMAIN: Final = "vitesy" + +LOGGER: Final = logging.getLogger(__package__) diff --git a/homeassistant/components/vitesy/coordinator.py b/homeassistant/components/vitesy/coordinator.py new file mode 100644 index 000000000000..074fabbdb3a3 --- /dev/null +++ b/homeassistant/components/vitesy/coordinator.py @@ -0,0 +1,81 @@ +"""Coordinator for the Vitesy integration.""" + +from collections.abc import Iterator +from contextlib import contextmanager +from datetime import timedelta +from typing import override + +from aiovitesy.api import VitesyApi, VitesyDevice +from aiovitesy.exceptions import CannotAuthenticate, VitesyError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + +type VitesyConfigEntry = ConfigEntry[VitesyDataUpdateCoordinator] + +UPDATE_INTERVAL = timedelta(minutes=5) + + +@contextmanager +def _translate_errors(*, auth_recoverable: bool = False) -> Iterator[None]: + """Convert aiovitesy errors into coordinator setup/update failures. + + On a refresh, auth_recoverable must be True: the config flow has no + reauth step yet, so raising ConfigEntryAuthFailed there would stop + polling forever with no way to recover. + """ + try: + yield + except CannotAuthenticate as err: + if auth_recoverable: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except VitesyError as err: + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="update_failed" + ) from err + + +class VitesyDataUpdateCoordinator(DataUpdateCoordinator[dict[str, VitesyDevice]]): + """Fetch state for every device of a Vitesy Hub account.""" + + config_entry: VitesyConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: VitesyConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) + self.api = VitesyApi( + config_entry.data[CONF_EMAIL], + config_entry.data[CONF_PASSWORD], + async_get_clientsession(hass), + ) + + @override + async def _async_setup(self) -> None: + """Authenticate against Vitesy Hub before the first refresh.""" + with _translate_errors(): + await self.api.login() + + @override + async def _async_update_data(self) -> dict[str, VitesyDevice]: + """Fetch the latest state for every device in the account.""" + with _translate_errors(auth_recoverable=True): + return await self.api.get_all_devices() diff --git a/homeassistant/components/vitesy/entity.py b/homeassistant/components/vitesy/entity.py new file mode 100644 index 000000000000..464706754b86 --- /dev/null +++ b/homeassistant/components/vitesy/entity.py @@ -0,0 +1,51 @@ +"""Base entity for the Vitesy integration.""" + +from typing import override + +from aiovitesy.api import VitesyDevice + +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import VitesyDataUpdateCoordinator + + +class VitesyEntity(CoordinatorEntity[VitesyDataUpdateCoordinator]): + """Base class for all Vitesy entities.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: VitesyDataUpdateCoordinator, device_id: str + ) -> None: + """Initialize the entity for a single Vitesy device.""" + super().__init__(coordinator) + self._device_id = device_id + + device = self.device + # The Vitesy Hub device id is the device's MAC address. + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + connections={(CONNECTION_NETWORK_MAC, device_id)}, + manufacturer="Vitesy", + model=device.device_type or None, + model_id=device.model or None, + name=device.name, + sw_version=device.firmware_version or None, + ) + + @property + def device(self) -> VitesyDevice: + """Return this entity's device from the latest coordinator data.""" + return self.coordinator.data[self._device_id] + + @property + @override + def available(self) -> bool: + """Return True when the device is present and reporting as connected.""" + return ( + super().available + and self._device_id in self.coordinator.data + and self.device.connected + ) diff --git a/homeassistant/components/vitesy/icons.json b/homeassistant/components/vitesy/icons.json new file mode 100644 index 000000000000..cb914d0eec62 --- /dev/null +++ b/homeassistant/components/vitesy/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "air_quality_score": { + "default": "mdi:air-filter" + }, + "door_openings": { + "default": "mdi:door-open" + } + } + } +} diff --git a/homeassistant/components/vitesy/manifest.json b/homeassistant/components/vitesy/manifest.json new file mode 100644 index 000000000000..2cb90a18478d --- /dev/null +++ b/homeassistant/components/vitesy/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "vitesy", + "name": "Vitesy", + "codeowners": ["@chemelli74"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/vitesy", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["aiovitesy"], + "quality_scale": "bronze", + "requirements": ["aiovitesy==1.1.0"] +} diff --git a/homeassistant/components/vitesy/quality_scale.yaml b/homeassistant/components/vitesy/quality_scale.yaml new file mode 100644 index 000000000000..6a7cde39fcc7 --- /dev/null +++ b/homeassistant/components/vitesy/quality_scale.yaml @@ -0,0 +1,78 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration does not register any custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: The integration does not register any custom actions. + docs-conditions: + status: exempt + comment: The integration does not register any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: The integration does not register any triggers. + 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: The integration does not register any custom actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: The integration is cloud based and has no discovery. + discovery: + status: exempt + comment: The integration is cloud based and has no discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: The integration has no repairable issues. + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/vitesy/sensor.py b/homeassistant/components/vitesy/sensor.py new file mode 100644 index 000000000000..71ef580f173c --- /dev/null +++ b/homeassistant/components/vitesy/sensor.py @@ -0,0 +1,190 @@ +"""Sensor platform for the Vitesy integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import override + +from aiovitesy.api import VitesyDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util + +from .const import LOGGER +from .coordinator import VitesyConfigEntry, VitesyDataUpdateCoordinator +from .entity import VitesyEntity + +PARALLEL_UPDATES = 0 + +# Groups within a measurement payload. +_SENSORS_DATA = "sensors_data" +_STATUS_DATA = "status_data" + +# Reading ids within those groups (Shelfy). +_FRIDGE_TEMPERATURE = "TMP01-SY" +_DOOR_OPENINGS = "DOC-SY" +_DOOR_OPEN_DURATION = "DOT-SY" +_BATTERY = "battery" + + +def _reading(device: VitesyDevice, group: str, reading_id: str) -> float | None: + """Return a numeric value from a measurement group entry by its id.""" + for entry in device.measurement.get(group, ()): + if entry.get("id") == reading_id: + value = entry.get("value") + value = value.get("avg") if isinstance(value, dict) else value + if isinstance(value, (int, float)): + return value + LOGGER.warning( + "Ignoring non-numeric value for reading %s on %s: %r", + reading_id, + device.name, + value, + ) + return None + return None + + +def _air_quality_score(device: VitesyDevice) -> float | None: + """Return the air quality score. + + The Hub reports it as a 0-1 fraction; Vitesy's canonical scale is 0-100. + """ + score = device.measurement.get("score") + return score * 100 if score is not None else None + + +def _maintenance_due(component: str) -> Callable[[VitesyDevice], datetime | None]: + """Return a value function for a maintenance component's due date.""" + + def _value(device: VitesyDevice) -> datetime | None: + due_date = device.maintenance.get(component, {}).get("due_date") + if not due_date: + return None + try: + return dt_util.parse_datetime(due_date, raise_on_error=True) + except ValueError: + LOGGER.warning( + "Ignoring unparsable %s due date for %s: %s", + component, + device.name, + due_date, + ) + return None + + return _value + + +@dataclass(frozen=True, kw_only=True) +class VitesySensorEntityDescription(SensorEntityDescription): + """Describes a Vitesy sensor entity.""" + + value_fn: Callable[[VitesyDevice], StateType | datetime] + + +SENSORS: tuple[VitesySensorEntityDescription, ...] = ( + VitesySensorEntityDescription( + key="air_quality_score", + translation_key="air_quality_score", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + value_fn=_air_quality_score, + ), + VitesySensorEntityDescription( + key="fridge_temperature", + translation_key="fridge_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: _reading(device, _SENSORS_DATA, _FRIDGE_TEMPERATURE), + ), + VitesySensorEntityDescription( + key="door_openings", + translation_key="door_openings", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: _reading(device, _SENSORS_DATA, _DOOR_OPENINGS), + ), + VitesySensorEntityDescription( + key="door_open_duration", + translation_key="door_open_duration", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: _reading(device, _SENSORS_DATA, _DOOR_OPEN_DURATION), + ), + VitesySensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: _reading(device, _STATUS_DATA, _BATTERY), + ), + VitesySensorEntityDescription( + key="filter_change_due", + translation_key="filter_change_due", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=_maintenance_due("filter"), + ), + VitesySensorEntityDescription( + key="fridge_cleaning_due", + translation_key="fridge_cleaning_due", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=_maintenance_due("fridge"), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: VitesyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Vitesy sensors from a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + VitesySensor(coordinator, device_id, description) + for device_id, device in coordinator.data.items() + for description in SENSORS + if description.value_fn(device) is not None + ) + + +class VitesySensor(VitesyEntity, SensorEntity): + """Representation of a Vitesy sensor.""" + + entity_description: VitesySensorEntityDescription + + def __init__( + self, + coordinator: VitesyDataUpdateCoordinator, + device_id: str, + description: VitesySensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, device_id) + self.entity_description = description + self._attr_unique_id = f"{device_id}_{description.key}" + + @property + @override + def native_value(self) -> StateType | datetime: + """Return the current value of the sensor.""" + return self.entity_description.value_fn(self.device) diff --git a/homeassistant/components/vitesy/strings.json b/homeassistant/components/vitesy/strings.json new file mode 100644 index 000000000000..e863f119e92e --- /dev/null +++ b/homeassistant/components/vitesy/strings.json @@ -0,0 +1,53 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "The email address of your Vitesy Hub account.", + "password": "The password of your Vitesy Hub account." + } + } + } + }, + "entity": { + "sensor": { + "air_quality_score": { + "name": "Air quality score" + }, + "door_open_duration": { + "name": "Door open duration" + }, + "door_openings": { + "name": "Door openings" + }, + "filter_change_due": { + "name": "Filter change due" + }, + "fridge_cleaning_due": { + "name": "Fridge cleaning due" + }, + "fridge_temperature": { + "name": "Fridge temperature" + } + } + }, + "exceptions": { + "invalid_auth": { + "message": "Vitesy Hub credentials are no longer valid." + }, + "update_failed": { + "message": "Error communicating with Vitesy Hub, check logs for details" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b660decfff7d..0072859b6b52 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -874,6 +874,7 @@ FLOWS = { "victron_remote_monitoring", "vilfo", "vistapool", + "vitesy", "vivotek", "vizio", "vlc_telnet", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ec10ab790188..5de0ae48ae91 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -8056,6 +8056,12 @@ "config_flow": true, "iot_class": "cloud_push" }, + "vitesy": { + "name": "Vitesy", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "vivotek": { "name": "VIVOTEK", "integration_type": "device", diff --git a/mypy.ini b/mypy.ini index e5db6c52e19d..681a3c147fe5 100644 --- a/mypy.ini +++ b/mypy.ini @@ -6241,6 +6241,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.vitesy.*] +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.vivotek.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index be06043f8cc8..a2b06f967eba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -470,6 +470,9 @@ aiounifi==96 # homeassistant.components.usb aiousbwatcher==1.1.2 +# homeassistant.components.vitesy +aiovitesy==1.1.0 + # homeassistant.components.vlc_telnet aiovlc==0.5.1 diff --git a/tests/components/vitesy/__init__.py b/tests/components/vitesy/__init__.py new file mode 100644 index 000000000000..82b05b0911a7 --- /dev/null +++ b/tests/components/vitesy/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Vitesy integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the Vitesy integration for testing.""" + 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/vitesy/conftest.py b/tests/components/vitesy/conftest.py new file mode 100644 index 000000000000..6820101de050 --- /dev/null +++ b/tests/components/vitesy/conftest.py @@ -0,0 +1,94 @@ +"""Common fixtures for the Vitesy tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from aiovitesy.api import VitesyDevice +import pytest + +from homeassistant.components.vitesy.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from tests.common import MockConfigEntry + +DEVICE_ID = "80:65:99:34:F9:B4" +EMAIL = "test@example.com" +USER_ID = "u1" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.vitesy.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_devices() -> dict[str, VitesyDevice]: + """Return a mocked Vitesy account holding a single device.""" + return { + DEVICE_ID: VitesyDevice( + device_id=DEVICE_ID, + name="Kitchen Shelfy", + model="SH02AA02", + device_type="SHELFY-R1", + firmware_version="1.2.3", + connected=True, + program_id="eco-s1", + data={"id": DEVICE_ID}, + measurement={ + "score": 0.49045833333333333, + "timestamp": "2026-09-10T17:19:38+02:00", + "sensors_data": [ + { + "id": "TMP01-SY", + "value": {"avg": 9.59, "min": 9.59, "max": 9.59}, + }, + {"id": "DOC-SY", "value": {"avg": 11, "min": 11, "max": 11}}, + {"id": "DOT-SY", "value": {"avg": 205, "min": 205, "max": 205}}, + ], + "status_data": [ + {"id": "battery", "value": {"avg": 27, "min": 27, "max": 27}}, + {"id": "charging", "value": False}, + {"id": "mode", "value": "eco"}, + {"id": "firmware", "value": "1.2.3"}, + ], + }, + maintenance={ + "filter": {"due_date": "2026-08-02T06:45:07.623Z"}, + "fridge": {"due_date": "2026-10-31T06:45:07.623Z"}, + }, + programs={}, + ) + } + + +@pytest.fixture +def mock_vitesy_client( + mock_devices: dict[str, VitesyDevice], +) -> Generator[AsyncMock]: + """Mock the aiovitesy client used by the coordinator and the config flow.""" + with ( + patch( + "homeassistant.components.vitesy.coordinator.VitesyApi", autospec=True + ) as mock_client, + patch("homeassistant.components.vitesy.config_flow.VitesyApi", new=mock_client), + ): + client = mock_client.return_value + client.login = AsyncMock(return_value=None) + client.get_user = AsyncMock(return_value={"id": USER_ID}) + client.get_all_devices = AsyncMock(return_value=mock_devices) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=EMAIL, + data={CONF_EMAIL: EMAIL, CONF_PASSWORD: "hunter2"}, + unique_id=USER_ID, + ) diff --git a/tests/components/vitesy/snapshots/test_sensor.ambr b/tests/components/vitesy/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..503637b7ca6c --- /dev/null +++ b/tests/components/vitesy/snapshots/test_sensor.ambr @@ -0,0 +1,384 @@ +# serializer version: 1 +# name: test_all_entities[sensor.kitchen_shelfy_air_quality_score-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_shelfy_air_quality_score', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Air quality score', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air quality score', + 'platform': 'vitesy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality_score', + 'unique_id': '80:65:99:34:F9:B4_air_quality_score', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.kitchen_shelfy_air_quality_score-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Kitchen Shelfy Air quality score', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.kitchen_shelfy_air_quality_score', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49.0458333333333', + }) +# --- +# name: test_all_entities[sensor.kitchen_shelfy_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.kitchen_shelfy_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'vitesy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '80:65:99:34:F9:B4_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.kitchen_shelfy_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery', + : 'Kitchen Shelfy Battery', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.kitchen_shelfy_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27', + }) +# --- +# name: test_all_entities[sensor.kitchen_shelfy_door_open_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_shelfy_door_open_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Door open duration', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door open duration', + 'platform': 'vitesy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_open_duration', + 'unique_id': '80:65:99:34:F9:B4_door_open_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.kitchen_shelfy_door_open_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Kitchen Shelfy Door open duration', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.kitchen_shelfy_door_open_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '205', + }) +# --- +# name: test_all_entities[sensor.kitchen_shelfy_door_openings-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_shelfy_door_openings', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Door openings', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Door openings', + 'platform': 'vitesy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_openings', + 'unique_id': '80:65:99:34:F9:B4_door_openings', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.kitchen_shelfy_door_openings-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Kitchen Shelfy Door openings', + : , + }), + 'context': , + 'entity_id': 'sensor.kitchen_shelfy_door_openings', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11', + }) +# --- +# name: test_all_entities[sensor.kitchen_shelfy_filter_change_due-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.kitchen_shelfy_filter_change_due', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filter change due', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter change due', + 'platform': 'vitesy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_change_due', + 'unique_id': '80:65:99:34:F9:B4_filter_change_due', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.kitchen_shelfy_filter_change_due-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Kitchen Shelfy Filter change due', + }), + 'context': , + 'entity_id': 'sensor.kitchen_shelfy_filter_change_due', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-08-02T06:45:07+00:00', + }) +# --- +# name: test_all_entities[sensor.kitchen_shelfy_fridge_cleaning_due-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.kitchen_shelfy_fridge_cleaning_due', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Fridge cleaning due', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fridge cleaning due', + 'platform': 'vitesy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fridge_cleaning_due', + 'unique_id': '80:65:99:34:F9:B4_fridge_cleaning_due', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.kitchen_shelfy_fridge_cleaning_due-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Kitchen Shelfy Fridge cleaning due', + }), + 'context': , + 'entity_id': 'sensor.kitchen_shelfy_fridge_cleaning_due', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-10-31T06:45:07+00:00', + }) +# --- +# name: test_all_entities[sensor.kitchen_shelfy_fridge_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_shelfy_fridge_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Fridge temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fridge temperature', + 'platform': 'vitesy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fridge_temperature', + 'unique_id': '80:65:99:34:F9:B4_fridge_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.kitchen_shelfy_fridge_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Kitchen Shelfy Fridge temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.kitchen_shelfy_fridge_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.59', + }) +# --- diff --git a/tests/components/vitesy/test_config_flow.py b/tests/components/vitesy/test_config_flow.py new file mode 100644 index 000000000000..3cc5d9d8d6c1 --- /dev/null +++ b/tests/components/vitesy/test_config_flow.py @@ -0,0 +1,114 @@ +"""Test the Vitesy config flow.""" + +from unittest.mock import AsyncMock + +from aiovitesy.exceptions import CannotAuthenticate, CannotConnect, GenericResponseError +import pytest + +from homeassistant.components.vitesy.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import EMAIL, USER_ID + +from tests.common import MockConfigEntry + +USER_INPUT = {CONF_EMAIL: EMAIL, CONF_PASSWORD: "hunter2"} + + +async def test_full_flow( + hass: HomeAssistant, mock_vitesy_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test the happy path creates an entry keyed on the account id.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == EMAIL + assert result["data"] == USER_INPUT + assert result["result"].unique_id == USER_ID + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "reason"), + [ + (CannotAuthenticate, "invalid_auth"), + (CannotConnect, "cannot_connect"), + (GenericResponseError, "cannot_connect"), + ], +) +async def test_form_errors_then_recovers( + hass: HomeAssistant, + mock_vitesy_client: AsyncMock, + mock_setup_entry: AsyncMock, + side_effect: type[Exception], + reason: str, +) -> None: + """Test each login failure surfaces an error and the flow can recover.""" + mock_vitesy_client.login.side_effect = side_effect + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": reason} + + mock_vitesy_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == USER_INPUT + + +async def test_form_invalid_user_id( + hass: HomeAssistant, + mock_vitesy_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test a profile response without a usable id surfaces a recoverable error.""" + mock_vitesy_client.get_user.return_value = {"id": None} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_vitesy_client.get_user.return_value = {"id": USER_ID} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == USER_INPUT + + +async def test_already_configured( + hass: HomeAssistant, + mock_vitesy_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the same account cannot be added twice.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/vitesy/test_init.py b/tests/components/vitesy/test_init.py new file mode 100644 index 000000000000..c40e0c311061 --- /dev/null +++ b/tests/components/vitesy/test_init.py @@ -0,0 +1,99 @@ +"""Test the Vitesy integration setup and teardown.""" + +from unittest.mock import AsyncMock + +from aiovitesy.exceptions import CannotAuthenticate, CannotConnect, VitesyError +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.vitesy.coordinator import UPDATE_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_setup_and_unload( + hass: HomeAssistant, + mock_vitesy_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a config entry loads and unloads cleanly.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_auth_failure( + hass: HomeAssistant, + mock_vitesy_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test rejected credentials put the entry in an error state.""" + mock_vitesy_client.login.side_effect = CannotAuthenticate + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_setup_connection_failure_is_retried( + hass: HomeAssistant, + mock_vitesy_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a transient connection error triggers a setup retry.""" + mock_vitesy_client.login.side_effect = CannotConnect + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_update_failure_marks_entities_unavailable( + hass: HomeAssistant, + mock_vitesy_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a failed refresh marks the entities unavailable.""" + await setup_integration(hass, mock_config_entry) + + mock_vitesy_client.get_all_devices.side_effect = VitesyError("boom") + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.kitchen_shelfy_air_quality_score").state + == STATE_UNAVAILABLE + ) + + +async def test_update_auth_failure( + hass: HomeAssistant, + mock_vitesy_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test rejected credentials during a refresh keep the coordinator polling.""" + await setup_integration(hass, mock_config_entry) + + mock_vitesy_client.get_all_devices.side_effect = CannotAuthenticate + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert not mock_config_entry.runtime_data.last_update_success + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_vitesy_client.get_all_devices.side_effect = None + mock_vitesy_client.get_all_devices.return_value = {} + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_config_entry.runtime_data.last_update_success diff --git a/tests/components/vitesy/test_sensor.py b/tests/components/vitesy/test_sensor.py new file mode 100644 index 000000000000..4463739b1d8d --- /dev/null +++ b/tests/components/vitesy/test_sensor.py @@ -0,0 +1,154 @@ +"""Test the Vitesy sensor platform.""" + +from typing import Any +from unittest.mock import AsyncMock + +from aiovitesy.api import VitesyDevice +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.vitesy.coordinator import UPDATE_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import DEVICE_ID + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +AIR_QUALITY_SCORE = "sensor.kitchen_shelfy_air_quality_score" +FILTER_CHANGE_DUE = "sensor.kitchen_shelfy_filter_change_due" +FRIDGE_TEMPERATURE = "sensor.kitchen_shelfy_fridge_temperature" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vitesy_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_sensor_unavailable_when_device_disconnected( + hass: HomeAssistant, + mock_vitesy_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_devices: dict[str, VitesyDevice], + freezer: FrozenDateTimeFactory, +) -> None: + """Test sensors go unavailable when the device reports as disconnected.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(AIR_QUALITY_SCORE).state == "49.0458333333333" + + mock_devices[DEVICE_ID].connected = False + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(AIR_QUALITY_SCORE).state == STATE_UNAVAILABLE + + +async def test_air_quality_score_without_value( + hass: HomeAssistant, + mock_vitesy_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_devices: dict[str, VitesyDevice], + freezer: FrozenDateTimeFactory, +) -> None: + """Test the score sensor reports unknown when the measurement drops it.""" + await setup_integration(hass, mock_config_entry) + + mock_devices[DEVICE_ID].measurement = { + **mock_devices[DEVICE_ID].measurement, + "score": None, + } + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(AIR_QUALITY_SCORE).state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("attribute", "update", "entity_id", "warning"), + [ + pytest.param( + "measurement", + {"sensors_data": [{"id": "TMP01-SY", "value": {"avg": "n/a"}}]}, + FRIDGE_TEMPERATURE, + "Ignoring non-numeric value for reading TMP01-SY on Kitchen Shelfy: 'n/a'", + id="reading_non_numeric_avg", + ), + pytest.param( + "measurement", + {"sensors_data": [{"id": "TMP01-SY", "value": "n/a"}]}, + FRIDGE_TEMPERATURE, + "Ignoring non-numeric value for reading TMP01-SY on Kitchen Shelfy: 'n/a'", + id="reading_non_numeric_scalar", + ), + pytest.param( + "maintenance", + {"filter": {"due_date": "not-a-date"}}, + FILTER_CHANGE_DUE, + "Ignoring unparsable filter due date for Kitchen Shelfy: not-a-date", + id="due_date_unparsable", + ), + ], +) +async def test_sensor_unknown_on_invalid_value( + hass: HomeAssistant, + mock_vitesy_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_devices: dict[str, VitesyDevice], + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, + attribute: str, + update: dict[str, Any], + entity_id: str, + warning: str, +) -> None: + """Test a sensor reports unknown and warns when its value turns invalid.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get(entity_id).state != STATE_UNKNOWN + + device = mock_devices[DEVICE_ID] + setattr(device, attribute, {**getattr(device, attribute), **update}) + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert warning in caplog.text + + +async def test_sensors_absent_from_measurement_are_not_created( + hass: HomeAssistant, + mock_vitesy_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_devices: dict[str, VitesyDevice], +) -> None: + """Test only the readings the device actually reports become entities.""" + device = mock_devices[DEVICE_ID] + device.measurement = {"score": 0.5} + device.maintenance = {} + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(AIR_QUALITY_SCORE) is not None + for absent in ( + "battery", + "fridge_temperature", + "door_openings", + "door_open_duration", + "filter_change_due", + "fridge_cleaning_due", + ): + assert hass.states.get(f"sensor.kitchen_shelfy_{absent}") is None