diff --git a/.strict-typing b/.strict-typing index 34961f012c0cc9..fa8588e3dc55db 100644 --- a/.strict-typing +++ b/.strict-typing @@ -496,6 +496,7 @@ homeassistant.components.smtp.* homeassistant.components.snooz.* homeassistant.components.solarlog.* homeassistant.components.sonarr.* +homeassistant.components.spaceapi.* homeassistant.components.speedtestdotnet.* homeassistant.components.spotify.* homeassistant.components.sql.* diff --git a/CODEOWNERS b/CODEOWNERS index f81e1b94719cdf..c53d65b595732a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1068,6 +1068,8 @@ build.json @home-assistant/supervisor /homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco /tests/components/mqtt/ @emontnemery @jbouwh @bdraco /homeassistant/components/msteams/ @peroyvind +/homeassistant/components/mta/ @OnFreund +/tests/components/mta/ @OnFreund /homeassistant/components/mullvad/ @meichthys /tests/components/mullvad/ @meichthys /homeassistant/components/music_assistant/ @music-assistant @arturpragacz diff --git a/homeassistant/components/advantage_air/quality_scale.yaml b/homeassistant/components/advantage_air/quality_scale.yaml new file mode 100644 index 00000000000000..9c87ce4213ed5e --- /dev/null +++ b/homeassistant/components/advantage_air/quality_scale.yaml @@ -0,0 +1,108 @@ +rules: + # Bronze + action-setup: + status: todo + comment: https://developers.home-assistant.io/blog/2025/09/25/entity-services-api-changes/ + appropriate-polling: done + brands: done + common-modules: + status: todo + comment: | + Move coordinator from __init__.py to coordinator.py. + Consider using entity descriptions for binary_sensor and switch. + Consider simplifying climate supported features flow. + config-flow-test-coverage: + status: todo + comment: | + Add mock_setup_entry common fixture. + Test unique_id of the entry in happy flow. + Split duplicate entry test from happy flow, use mock_config_entry. + Error flow should end in CREATE_ENTRY to test recovery. + Add data_description for ip_address (and port) to strings.json - tests fail with: + "Translation not found for advantage_air: config.step.user.data_description.ip_address" + config-flow: + status: todo + comment: Data descriptions missing + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: Entities do not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: + status: done + comment: Consider extending coordinator to access API via coordinator and remove extra dataclass. + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to be set. + docs-installation-parameters: done + entity-unavailable: + status: todo + comment: MyZone temp entity should be unavailable when MyZone is disabled rather than returning None. + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: Integration connects to local device without authentication. + test-coverage: + status: todo + comment: | + Patch the library instead of mocking at integration level. + Split binary sensor tests into multiple tests (enable entities etc). + Split tests into Creation (right entities with right values), Actions (right library calls), and Other behaviors. + + # Gold + devices: + status: todo + comment: Consider making every zone its own device for better naming and room assignment. Breaking change to split cover entities to separate devices. + diagnostics: done + discovery-update-info: + status: exempt + comment: Device is a generic Android device (android-xxxxxxxx) indistinguishable from other Android devices, not discoverable. + discovery: + status: exempt + comment: Check mDNS, DHCP, SSDP confirmed not feasible. Device is a generic Android device (android-xxxxxxxx) indistinguishable from other Android devices. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: AC zones are static per unit and configured on the device itself. + entity-category: done + entity-device-class: + status: todo + comment: Consider using UPDATE device class for app update binary sensor instead of custom. + entity-disabled-by-default: done + entity-translations: todo + exception-translations: + status: todo + comment: UpdateFailed in the coordinator + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Integration does not raise repair issues. + stale-devices: + status: exempt + comment: Zones are part of the AC unit, not separate removable devices. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/aosmith/water_heater.py b/homeassistant/components/aosmith/water_heater.py index d29b00955b6e14..3f88fdd497dae7 100644 --- a/homeassistant/components/aosmith/water_heater.py +++ b/homeassistant/components/aosmith/water_heater.py @@ -120,7 +120,7 @@ def current_operation(self) -> str: return MODE_AOSMITH_TO_HA.get(self.device.status.current_mode, STATE_OFF) @property - def is_away_mode_on(self): + def is_away_mode_on(self) -> bool: """Return True if away mode is on.""" return self.device.status.current_mode == AOSmithOperationMode.VACATION diff --git a/homeassistant/components/atag/water_heater.py b/homeassistant/components/atag/water_heater.py index 286857f17eb543..a409c3cecfa82f 100644 --- a/homeassistant/components/atag/water_heater.py +++ b/homeassistant/components/atag/water_heater.py @@ -37,15 +37,15 @@ class AtagWaterHeater(AtagEntity, WaterHeaterEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS @property - def current_temperature(self): + def current_temperature(self) -> float: """Return the current temperature.""" return self.coordinator.atag.dhw.temperature @property - def current_operation(self): + def current_operation(self) -> str: """Return current operation.""" operation = self.coordinator.atag.dhw.current_operation - return operation if operation in self.operation_list else STATE_OFF + return operation if operation in OPERATION_LIST else STATE_OFF async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -53,7 +53,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: self.async_write_ha_state() @property - def target_temperature(self): + def target_temperature(self) -> float: """Return the setpoint if water demand, otherwise return base temp (comfort level).""" return self.coordinator.atag.dhw.target_temperature diff --git a/homeassistant/components/control4/climate.py b/homeassistant/components/control4/climate.py index d28fceb8bbe8cd..8669d09122deca 100644 --- a/homeassistant/components/control4/climate.py +++ b/homeassistant/components/control4/climate.py @@ -34,20 +34,33 @@ # Control4 variable names CONTROL4_HVAC_STATE = "HVAC_STATE" CONTROL4_HVAC_MODE = "HVAC_MODE" -CONTROL4_CURRENT_TEMPERATURE = "TEMPERATURE_F" CONTROL4_HUMIDITY = "HUMIDITY" -CONTROL4_COOL_SETPOINT = "COOL_SETPOINT_F" -CONTROL4_HEAT_SETPOINT = "HEAT_SETPOINT_F" +CONTROL4_SCALE = "SCALE" # "FAHRENHEIT" or "CELSIUS" + +# Temperature variables - Fahrenheit +CONTROL4_CURRENT_TEMPERATURE_F = "TEMPERATURE_F" +CONTROL4_COOL_SETPOINT_F = "COOL_SETPOINT_F" +CONTROL4_HEAT_SETPOINT_F = "HEAT_SETPOINT_F" + +# Temperature variables - Celsius +CONTROL4_CURRENT_TEMPERATURE_C = "TEMPERATURE_C" +CONTROL4_COOL_SETPOINT_C = "COOL_SETPOINT_C" +CONTROL4_HEAT_SETPOINT_C = "HEAT_SETPOINT_C" + CONTROL4_FAN_MODE = "FAN_MODE" CONTROL4_FAN_MODES_LIST = "FAN_MODES_LIST" VARIABLES_OF_INTEREST = { CONTROL4_HVAC_STATE, CONTROL4_HVAC_MODE, - CONTROL4_CURRENT_TEMPERATURE, CONTROL4_HUMIDITY, - CONTROL4_COOL_SETPOINT, - CONTROL4_HEAT_SETPOINT, + CONTROL4_CURRENT_TEMPERATURE_F, + CONTROL4_CURRENT_TEMPERATURE_C, + CONTROL4_COOL_SETPOINT_F, + CONTROL4_HEAT_SETPOINT_F, + CONTROL4_COOL_SETPOINT_C, + CONTROL4_HEAT_SETPOINT_C, + CONTROL4_SCALE, CONTROL4_FAN_MODE, CONTROL4_FAN_MODES_LIST, } @@ -156,7 +169,6 @@ class Control4Climate(Control4Entity, ClimateEntity): """Control4 climate entity.""" _attr_has_entity_name = True - _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT _attr_translation_key = "thermostat" _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL] @@ -213,13 +225,45 @@ def supported_features(self) -> ClimateEntityFeature: features |= ClimateEntityFeature.FAN_MODE return features + @property + def temperature_unit(self) -> str: + """Return the temperature unit based on the thermostat's SCALE setting.""" + data = self._thermostat_data + if data is None: + return UnitOfTemperature.CELSIUS # Default per HA conventions + if data.get(CONTROL4_SCALE) == "FAHRENHEIT": + return UnitOfTemperature.FAHRENHEIT + return UnitOfTemperature.CELSIUS + + @property + def _cool_setpoint(self) -> float | None: + """Return the cooling setpoint from the appropriate variable.""" + data = self._thermostat_data + if data is None: + return None + if self.temperature_unit == UnitOfTemperature.CELSIUS: + return data.get(CONTROL4_COOL_SETPOINT_C) + return data.get(CONTROL4_COOL_SETPOINT_F) + + @property + def _heat_setpoint(self) -> float | None: + """Return the heating setpoint from the appropriate variable.""" + data = self._thermostat_data + if data is None: + return None + if self.temperature_unit == UnitOfTemperature.CELSIUS: + return data.get(CONTROL4_HEAT_SETPOINT_C) + return data.get(CONTROL4_HEAT_SETPOINT_F) + @property def current_temperature(self) -> float | None: """Return the current temperature.""" data = self._thermostat_data if data is None: return None - return data.get(CONTROL4_CURRENT_TEMPERATURE) + if self.temperature_unit == UnitOfTemperature.CELSIUS: + return data.get(CONTROL4_CURRENT_TEMPERATURE_C) + return data.get(CONTROL4_CURRENT_TEMPERATURE_F) @property def current_humidity(self) -> int | None: @@ -257,34 +301,25 @@ def hvac_action(self) -> HVACAction | None: @property def target_temperature(self) -> float | None: """Return the target temperature.""" - data = self._thermostat_data - if data is None: - return None hvac_mode = self.hvac_mode if hvac_mode == HVACMode.COOL: - return data.get(CONTROL4_COOL_SETPOINT) + return self._cool_setpoint if hvac_mode == HVACMode.HEAT: - return data.get(CONTROL4_HEAT_SETPOINT) + return self._heat_setpoint return None @property def target_temperature_high(self) -> float | None: """Return the high target temperature for auto mode.""" - data = self._thermostat_data - if data is None: - return None if self.hvac_mode == HVACMode.HEAT_COOL: - return data.get(CONTROL4_COOL_SETPOINT) + return self._cool_setpoint return None @property def target_temperature_low(self) -> float | None: """Return the low target temperature for auto mode.""" - data = self._thermostat_data - if data is None: - return None if self.hvac_mode == HVACMode.HEAT_COOL: - return data.get(CONTROL4_HEAT_SETPOINT) + return self._heat_setpoint return None @property @@ -326,15 +361,27 @@ async def async_set_temperature(self, **kwargs: Any) -> None: # Handle temperature range for auto mode if self.hvac_mode == HVACMode.HEAT_COOL: if low_temp is not None: - await c4_climate.setHeatSetpointF(low_temp) + if self.temperature_unit == UnitOfTemperature.CELSIUS: + await c4_climate.setHeatSetpointC(low_temp) + else: + await c4_climate.setHeatSetpointF(low_temp) if high_temp is not None: - await c4_climate.setCoolSetpointF(high_temp) + if self.temperature_unit == UnitOfTemperature.CELSIUS: + await c4_climate.setCoolSetpointC(high_temp) + else: + await c4_climate.setCoolSetpointF(high_temp) # Handle single temperature setpoint elif temp is not None: if self.hvac_mode == HVACMode.COOL: - await c4_climate.setCoolSetpointF(temp) + if self.temperature_unit == UnitOfTemperature.CELSIUS: + await c4_climate.setCoolSetpointC(temp) + else: + await c4_climate.setCoolSetpointF(temp) elif self.hvac_mode == HVACMode.HEAT: - await c4_climate.setHeatSetpointF(temp) + if self.temperature_unit == UnitOfTemperature.CELSIUS: + await c4_climate.setHeatSetpointC(temp) + else: + await c4_climate.setHeatSetpointF(temp) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index 648a65c0d30be4..e5ddf4c6a38c17 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -2,9 +2,12 @@ from __future__ import annotations +from collections.abc import Sequence import logging from typing import Any +from pydaikin.daikin_base import Appliance + from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_HVAC_MODE, @@ -21,6 +24,7 @@ ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -29,12 +33,19 @@ ATTR_STATE_OFF, ATTR_STATE_ON, ATTR_TARGET_TEMPERATURE, + DOMAIN, + ZONE_NAME_UNCONFIGURED, ) from .coordinator import DaikinConfigEntry, DaikinCoordinator from .entity import DaikinEntity _LOGGER = logging.getLogger(__name__) +type DaikinZone = Sequence[str | int] + +DAIKIN_ZONE_TEMP_HEAT = "lztemp_h" +DAIKIN_ZONE_TEMP_COOL = "lztemp_c" + HA_STATE_TO_DAIKIN = { HVACMode.FAN_ONLY: "fan", @@ -78,6 +89,70 @@ } DAIKIN_ATTR_ADVANCED = "adv" +ZONE_TEMPERATURE_WINDOW = 2 + + +def _zone_error( + translation_key: str, placeholders: dict[str, str] | None = None +) -> HomeAssistantError: + """Return a Home Assistant error with Daikin translation info.""" + return HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders=placeholders, + ) + + +def _zone_is_configured(zone: DaikinZone) -> bool: + """Return True if the Daikin zone represents a configured zone.""" + if not zone: + return False + return zone[0] != ZONE_NAME_UNCONFIGURED + + +def _zone_temperature_lists(device: Appliance) -> tuple[list[str], list[str]]: + """Return the decoded zone temperature lists.""" + try: + heating = device.represent(DAIKIN_ZONE_TEMP_HEAT)[1] + cooling = device.represent(DAIKIN_ZONE_TEMP_COOL)[1] + except AttributeError: + return ([], []) + return (list(heating or []), list(cooling or [])) + + +def _supports_zone_temperature_control(device: Appliance) -> bool: + """Return True if the device exposes zone temperature settings.""" + zones = device.zones + if not zones: + return False + heating, cooling = _zone_temperature_lists(device) + return bool( + heating + and cooling + and len(heating) >= len(zones) + and len(cooling) >= len(zones) + ) + + +def _system_target_temperature(device: Appliance) -> float | None: + """Return the system target temperature when available.""" + target = device.target_temperature + if target is None: + return None + try: + return float(target) + except TypeError, ValueError: + return None + + +def _zone_temperature_from_list(values: list[str], zone_id: int) -> float | None: + """Return the parsed temperature for a zone from a Daikin list.""" + if zone_id >= len(values): + return None + try: + return float(values[zone_id]) + except TypeError, ValueError: + return None async def async_setup_entry( @@ -86,8 +161,16 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Daikin climate based on config_entry.""" - daikin_api = entry.runtime_data - async_add_entities([DaikinClimate(daikin_api)]) + coordinator = entry.runtime_data + entities: list[ClimateEntity] = [DaikinClimate(coordinator)] + if _supports_zone_temperature_control(coordinator.device): + zones = coordinator.device.zones or [] + entities.extend( + DaikinZoneClimate(coordinator, zone_id) + for zone_id, zone in enumerate(zones) + if _zone_is_configured(zone) + ) + async_add_entities(entities) def format_target_temperature(target_temperature: float) -> str: @@ -284,3 +367,130 @@ async def async_turn_off(self) -> None: {HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE]: HA_STATE_TO_DAIKIN[HVACMode.OFF]} ) await self.coordinator.async_refresh() + + +class DaikinZoneClimate(DaikinEntity, ClimateEntity): + """Representation of a Daikin zone temperature controller.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_has_entity_name = True + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_target_temperature_step = 1 + + def __init__(self, coordinator: DaikinCoordinator, zone_id: int) -> None: + """Initialize the zone climate entity.""" + super().__init__(coordinator) + self._zone_id = zone_id + self._attr_unique_id = f"{self.device.mac}-zone{zone_id}-temperature" + zone_name = self.device.zones[self._zone_id][0] + self._attr_name = f"{zone_name} temperature" + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the hvac modes (mirrors the main unit).""" + return [self.hvac_mode] + + @property + def hvac_mode(self) -> HVACMode: + """Return the current HVAC mode.""" + daikin_mode = self.device.represent(HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE])[1] + return DAIKIN_TO_HA_STATE.get(daikin_mode, HVACMode.HEAT_COOL) + + @property + def hvac_action(self) -> HVACAction | None: + """Return the current HVAC action.""" + return HA_STATE_TO_CURRENT_HVAC.get(self.hvac_mode) + + @property + def target_temperature(self) -> float | None: + """Return the zone target temperature for the active mode.""" + heating, cooling = _zone_temperature_lists(self.device) + mode = self.hvac_mode + if mode == HVACMode.HEAT: + return _zone_temperature_from_list(heating, self._zone_id) + if mode == HVACMode.COOL: + return _zone_temperature_from_list(cooling, self._zone_id) + return None + + @property + def min_temp(self) -> float: + """Return the minimum selectable temperature.""" + target = _system_target_temperature(self.device) + if target is None: + return super().min_temp + return target - ZONE_TEMPERATURE_WINDOW + + @property + def max_temp(self) -> float: + """Return the maximum selectable temperature.""" + target = _system_target_temperature(self.device) + if target is None: + return super().max_temp + return target + ZONE_TEMPERATURE_WINDOW + + @property + def available(self) -> bool: + """Return if the entity is available.""" + return ( + super().available + and _supports_zone_temperature_control(self.device) + and _system_target_temperature(self.device) is not None + ) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return additional metadata.""" + return {"zone_id": self._zone_id} + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the zone temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="zone_temperature_missing", + ) + zones = self.device.zones + if not zones or not _supports_zone_temperature_control(self.device): + raise _zone_error("zone_parameters_unavailable") + + try: + zone = zones[self._zone_id] + except (IndexError, TypeError) as err: + raise _zone_error( + "zone_missing", + { + "zone_id": str(self._zone_id), + "max_zone": str(len(zones) - 1), + }, + ) from err + + if not _zone_is_configured(zone): + raise _zone_error("zone_inactive", {"zone_id": str(self._zone_id)}) + + temperature_value = float(temperature) + target = _system_target_temperature(self.device) + if target is None: + raise _zone_error("zone_parameters_unavailable") + + mode = self.hvac_mode + if mode == HVACMode.HEAT: + zone_key = DAIKIN_ZONE_TEMP_HEAT + elif mode == HVACMode.COOL: + zone_key = DAIKIN_ZONE_TEMP_COOL + else: + raise _zone_error("zone_hvac_mode_unsupported") + + zone_value = str(round(temperature_value)) + try: + await self.device.set_zone(self._zone_id, zone_key, zone_value) + except (AttributeError, KeyError, NotImplementedError, TypeError) as err: + raise _zone_error("zone_set_failed") from err + + await self.coordinator.async_request_refresh() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Disallow changing HVAC mode via zone climate.""" + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="zone_hvac_read_only", + ) diff --git a/homeassistant/components/daikin/const.py b/homeassistant/components/daikin/const.py index f093569ea54df7..27f0b9ba57d328 100644 --- a/homeassistant/components/daikin/const.py +++ b/homeassistant/components/daikin/const.py @@ -24,4 +24,6 @@ KEY_MAC = "mac" KEY_IP = "ip" +ZONE_NAME_UNCONFIGURED = "-" + TIMEOUT_SEC = 120 diff --git a/homeassistant/components/daikin/strings.json b/homeassistant/components/daikin/strings.json index 53645b1e7bd41c..b3326454d375b4 100644 --- a/homeassistant/components/daikin/strings.json +++ b/homeassistant/components/daikin/strings.json @@ -57,5 +57,28 @@ "name": "Power" } } + }, + "exceptions": { + "zone_hvac_mode_unsupported": { + "message": "Zone temperature can only be changed when the main climate mode is heat or cool." + }, + "zone_hvac_read_only": { + "message": "Zone HVAC mode is controlled by the main climate entity." + }, + "zone_inactive": { + "message": "Zone {zone_id} is not active. Enable the zone on your Daikin device first." + }, + "zone_missing": { + "message": "Zone {zone_id} does not exist. Available zones are 0-{max_zone}." + }, + "zone_parameters_unavailable": { + "message": "This device does not expose the required zone temperature parameters." + }, + "zone_set_failed": { + "message": "Failed to set zone temperature. The device may not support this operation." + }, + "zone_temperature_missing": { + "message": "Provide a temperature value when adjusting a zone." + } } } diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 20a56ac321cd6e..20d27e7d3ea372 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import ZONE_NAME_UNCONFIGURED from .coordinator import DaikinConfigEntry, DaikinCoordinator from .entity import DaikinEntity @@ -28,7 +29,7 @@ async def async_setup_entry( switches.extend( DaikinZoneSwitch(daikin_api, zone_id) for zone_id, zone in enumerate(zones) - if zone[0] != "-" + if zone[0] != ZONE_NAME_UNCONFIGURED ) if daikin_api.device.support_advanced_modes: # It isn't possible to find out from the API responses if a specific diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index f93ad7f8872e13..876d9270bc914f 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -136,12 +136,12 @@ def target_temperature(self) -> int: return self.water_heater.set_point @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" return self.water_heater.set_point_limits[0] @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" return self.water_heater.set_point_limits[1] diff --git a/homeassistant/components/facebook/notify.py b/homeassistant/components/facebook/notify.py index 674da78ead2dd6..ba998e79e3adf5 100644 --- a/homeassistant/components/facebook/notify.py +++ b/homeassistant/components/facebook/notify.py @@ -77,7 +77,7 @@ def send_message(self, message: str = "", **kwargs: Any) -> None: "recipient": recipient, "message": body_message, "messaging_type": "MESSAGE_TAG", - "tag": "ACCOUNT_UPDATE", + "tag": "HUMAN_AGENT", } resp = requests.post( BASE_URL, diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index 31f704fcaccadf..e19b1d280d2b97 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -8,15 +8,14 @@ from gios import Gios from gios.exceptions import GiosError -from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_STATION_ID, DOMAIN -from .coordinator import GiosConfigEntry, GiosData, GiosDataUpdateCoordinator +from .coordinator import GiosConfigEntry, GiosDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -56,19 +55,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: GiosConfigEntry) -> bool coordinator = GiosDataUpdateCoordinator(hass, entry, gios) await coordinator.async_config_entry_first_refresh() - entry.runtime_data = GiosData(coordinator) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Remove air_quality entities from registry if they exist - ent_reg = er.async_get(hass) - unique_id = str(coordinator.gios.station_id) - if entity_id := ent_reg.async_get_entity_id( - AIR_QUALITY_PLATFORM, DOMAIN, unique_id - ): - _LOGGER.debug("Removing deprecated air_quality entity %s", entity_id) - ent_reg.async_remove(entity_id) - return True diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py index 5745d15e72e811..eb83e92bc03356 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -38,14 +38,18 @@ async def async_step_user( if user_input is not None: station_id = user_input[CONF_STATION_ID] - try: - await self.async_set_unique_id(station_id, raise_on_progress=False) - self._abort_if_unique_id_configured() + await self.async_set_unique_id(station_id, raise_on_progress=False) + self._abort_if_unique_id_configured() + try: async with asyncio.timeout(API_TIMEOUT): gios = await Gios.create(websession, int(station_id)) await gios.async_update() - + except ApiError, ClientConnectorError, TimeoutError: + errors["base"] = "cannot_connect" + except InvalidSensorsDataError: + errors[CONF_STATION_ID] = "invalid_sensors_data" + else: # GIOS treats station ID as int user_input[CONF_STATION_ID] = int(station_id) @@ -60,10 +64,6 @@ async def async_step_user( # raising errors. data={**user_input, CONF_NAME: gios.station_name}, ) - except ApiError, ClientConnectorError, TimeoutError: - errors["base"] = "cannot_connect" - except InvalidSensorsDataError: - errors[CONF_STATION_ID] = "invalid_sensors_data" try: gios = await Gios.create(websession) diff --git a/homeassistant/components/gios/coordinator.py b/homeassistant/components/gios/coordinator.py index c80557da55f24e..60525b33edf297 100644 --- a/homeassistant/components/gios/coordinator.py +++ b/homeassistant/components/gios/coordinator.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass import logging from typing import TYPE_CHECKING @@ -22,14 +21,7 @@ _LOGGER = logging.getLogger(__name__) -type GiosConfigEntry = ConfigEntry[GiosData] - - -@dataclass -class GiosData: - """Data for GIOS integration.""" - - coordinator: GiosDataUpdateCoordinator +type GiosConfigEntry = ConfigEntry[GiosDataUpdateCoordinator] class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): diff --git a/homeassistant/components/gios/diagnostics.py b/homeassistant/components/gios/diagnostics.py index 7e938d5ac6b58a..e25f56dcbc70ab 100644 --- a/homeassistant/components/gios/diagnostics.py +++ b/homeassistant/components/gios/diagnostics.py @@ -14,7 +14,7 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: GiosConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data return { "config_entry": config_entry.as_dict(), diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 5cdd0d513a3362..e92e14ae555397 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -7,5 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], + "quality_scale": "platinum", "requirements": ["gios==7.0.0"] } diff --git a/homeassistant/components/gios/quality_scale.yaml b/homeassistant/components/gios/quality_scale.yaml index cab565d35cf838..f1b25b15b55cb5 100644 --- a/homeassistant/components/gios/quality_scale.yaml +++ b/homeassistant/components/gios/quality_scale.yaml @@ -1,7 +1,4 @@ rules: - # Other comments: - # - we could consider removing the air quality entity removal - # Bronze action-setup: status: exempt @@ -9,14 +6,8 @@ rules: appropriate-polling: done brands: done common-modules: done - config-flow-test-coverage: - status: todo - comment: - We should have the happy flow as the first test, which can be merged with test_show_form. - The config flow tests are missing adding a duplicate entry test. - config-flow: - status: todo - comment: Limit the scope of the try block in the user step + config-flow-test-coverage: done + config-flow: done dependency-transparency: done docs-actions: status: exempt @@ -27,9 +18,7 @@ rules: entity-event-setup: done entity-unique-id: done has-entity-name: done - runtime-data: - status: todo - comment: No direct need to wrap the coordinator in a dataclass to store in the config entry + runtime-data: done test-before-configure: done test-before-setup: done unique-config-entry: done @@ -50,11 +39,7 @@ rules: reauthentication-flow: status: exempt comment: This integration does not require authentication. - test-coverage: - status: todo - comment: - The `test_async_setup_entry` should test the state of the mock config entry, instead of an entity state - The `test_availability` doesn't really do what it says it does, and this is now already tested via the snapshot tests. + test-coverage: done # Gold devices: done @@ -78,13 +63,9 @@ rules: status: exempt comment: This integration does not have devices. entity-category: done - entity-device-class: - status: todo - comment: We can use the CO device class for the carbon monoxide sensor + entity-device-class: done entity-disabled-by-default: done - entity-translations: - status: todo - comment: We can remove the options state_attributes. + entity-translations: done exception-translations: done icon-translations: done reconfiguration-flow: diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 7fb6fcf431ce42..b51526ebcaf665 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -72,9 +72,9 @@ class GiosSensorEntityDescription(SensorEntityDescription): key=ATTR_CO, value=lambda sensors: sensors.co.value if sensors.co else None, suggested_display_precision=0, + device_class=SensorDeviceClass.CO, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, - translation_key="co", ), GiosSensorEntityDescription( key=ATTR_NO, @@ -181,7 +181,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a GIOS entities from a config_entry.""" - coordinator = entry.runtime_data.coordinator + coordinator = entry.runtime_data # Due to the change of the attribute name of one sensor, it is necessary to migrate # the unique_id to the new name. entity_registry = er.async_get(hass) diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index da9c246600a99f..09d9a1dfc7b7dd 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -31,26 +31,11 @@ "sufficient": "Sufficient", "very_bad": "Very bad", "very_good": "Very good" - }, - "state_attributes": { - "options": { - "state": { - "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", - "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", - "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", - "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", - "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", - "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - } - } } }, "c6h6": { "name": "Benzene" }, - "co": { - "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" - }, "no2_index": { "name": "Nitrogen dioxide index", "state": { @@ -60,18 +45,6 @@ "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - }, - "state_attributes": { - "options": { - "state": { - "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", - "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", - "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", - "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", - "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", - "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - } - } } }, "nox": { @@ -86,18 +59,6 @@ "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - }, - "state_attributes": { - "options": { - "state": { - "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", - "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", - "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", - "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", - "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", - "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - } - } } }, "pm10_index": { @@ -109,18 +70,6 @@ "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - }, - "state_attributes": { - "options": { - "state": { - "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", - "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", - "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", - "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", - "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", - "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - } - } } }, "pm25_index": { @@ -132,18 +81,6 @@ "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - }, - "state_attributes": { - "options": { - "state": { - "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", - "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", - "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", - "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", - "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", - "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - } - } } }, "so2_index": { @@ -155,18 +92,6 @@ "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - }, - "state_attributes": { - "options": { - "state": { - "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", - "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", - "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", - "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", - "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", - "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - } - } } } } diff --git a/homeassistant/components/growatt_server/number.py b/homeassistant/components/growatt_server/number.py index 7016c25cadb22b..a7006d13f1f718 100644 --- a/homeassistant/components/growatt_server/number.py +++ b/homeassistant/components/growatt_server/number.py @@ -68,15 +68,25 @@ class GrowattNumberEntityDescription(NumberEntityDescription, GrowattRequiredKey native_unit_of_measurement=PERCENTAGE, ), GrowattNumberEntityDescription( - key="battery_discharge_soc_limit", - translation_key="battery_discharge_soc_limit", - api_key="wdisChargeSOCLowLimit", # Key returned by V1 API + key="battery_discharge_soc_limit", # Keep original key to preserve unique_id + translation_key="battery_discharge_soc_limit_off_grid", + api_key="wdisChargeSOCLowLimit", # Key returned by V1 API (off-grid) write_key="discharge_stop_soc", # Key used to write parameter native_step=1, native_min_value=0, native_max_value=100, native_unit_of_measurement=PERCENTAGE, ), + GrowattNumberEntityDescription( + key="battery_discharge_soc_limit_on_grid", + translation_key="battery_discharge_soc_limit_on_grid", + api_key="onGridDischargeStopSOC", # Key returned by V1 API (on-grid) + write_key="on_grid_discharge_stop_soc", # Key used to write parameter + native_step=1, + native_min_value=0, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + ), ) diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json index ffb4654407934d..22443c586052d3 100644 --- a/homeassistant/components/growatt_server/strings.json +++ b/homeassistant/components/growatt_server/strings.json @@ -53,8 +53,11 @@ "battery_discharge_power_limit": { "name": "Battery discharge power limit" }, - "battery_discharge_soc_limit": { - "name": "Battery discharge SOC limit" + "battery_discharge_soc_limit_off_grid": { + "name": "Battery discharge SOC limit (off-grid)" + }, + "battery_discharge_soc_limit_on_grid": { + "name": "Battery discharge SOC limit (on-grid)" } }, "sensor": { diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py index 718baf346ae7be..c542de0a0aa3d3 100644 --- a/homeassistant/components/homee/const.py +++ b/homeassistant/components/homee/const.py @@ -31,6 +31,7 @@ "n/a": None, "text": None, "%": PERCENTAGE, + "Lux": LIGHT_LUX, "lx": LIGHT_LUX, "klx": LIGHT_LUX, "1/min": REVOLUTIONS_PER_MINUTE, diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index ff3354e5c77762..49f7522c03c647 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -4,7 +4,6 @@ from contextlib import suppress from datetime import datetime, timedelta -from functools import partial from http import HTTPStatus import json import logging @@ -13,7 +12,7 @@ from urllib.parse import urlparse import uuid -from aiohttp import web +from aiohttp import ClientSession, web from aiohttp.hdrs import AUTHORIZATION import jwt from py_vapid import Vapid @@ -35,6 +34,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import ensure_unique_string @@ -203,8 +203,9 @@ def websocket_appkey( hass.http.register_view(HTML5PushRegistrationView(registrations, json_path)) hass.http.register_view(HTML5PushCallbackView(registrations)) + session = async_get_clientsession(hass) return HTML5NotificationService( - hass, vapid_prv_key, vapid_email, registrations, json_path + hass, session, vapid_prv_key, vapid_email, registrations, json_path ) @@ -420,12 +421,14 @@ class HTML5NotificationService(BaseNotificationService): def __init__( self, hass: HomeAssistant, + session: ClientSession, vapid_prv: str, vapid_email: str, registrations: dict[str, Registration], json_path: str, ) -> None: """Initialize the service.""" + self.session = session self._vapid_prv = vapid_prv self._vapid_email = vapid_email self.registrations = registrations @@ -456,22 +459,18 @@ def targets(self) -> dict[str, str]: """Return a dictionary of registered targets.""" return {registration: registration for registration in self.registrations} - def dismiss(self, **kwargs: Any) -> None: - """Dismisses a notification.""" - data: dict[str, Any] | None = kwargs.get(ATTR_DATA) - tag: str = data.get(ATTR_TAG, "") if data else "" - payload = {ATTR_TAG: tag, ATTR_DISMISS: True, ATTR_DATA: {}} - - self._push_message(payload, **kwargs) - - async def async_dismiss(self, **kwargs) -> None: + async def async_dismiss(self, **kwargs: Any) -> None: """Dismisses a notification. This method must be run in the event loop. """ - await self.hass.async_add_executor_job(partial(self.dismiss, **kwargs)) + data: dict[str, Any] | None = kwargs.get(ATTR_DATA) + tag: str = data.get(ATTR_TAG, "") if data else "" + payload = {ATTR_TAG: tag, ATTR_DISMISS: True, ATTR_DATA: {}} + + await self._push_message(payload, **kwargs) - def send_message(self, message: str = "", **kwargs: Any) -> None: + async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a user.""" tag = str(uuid.uuid4()) payload: dict[str, Any] = { @@ -503,9 +502,9 @@ def send_message(self, message: str = "", **kwargs: Any) -> None: ): payload[ATTR_DATA][ATTR_URL] = URL_ROOT - self._push_message(payload, **kwargs) + await self._push_message(payload, **kwargs) - def _push_message(self, payload: dict[str, Any], **kwargs: Any) -> None: + async def _push_message(self, payload: dict[str, Any], **kwargs: Any) -> None: """Send the message.""" timestamp = int(time.time()) @@ -535,7 +534,9 @@ def _push_message(self, payload: dict[str, Any], **kwargs: Any) -> None: subscription["keys"]["auth"], ) - webpusher = WebPusher(cast(dict[str, Any], info["subscription"])) + webpusher = WebPusher( + cast(dict[str, Any], info["subscription"]), aiohttp_session=self.session + ) endpoint = urlparse(subscription["endpoint"]) vapid_claims = { @@ -545,28 +546,31 @@ def _push_message(self, payload: dict[str, Any], **kwargs: Any) -> None: } vapid_headers = Vapid.from_string(self._vapid_prv).sign(vapid_claims) vapid_headers.update({"urgency": priority, "priority": priority}) - response = webpusher.send( + + response = await webpusher.send_async( data=json.dumps(payload), headers=vapid_headers, ttl=ttl ) if TYPE_CHECKING: assert not isinstance(response, str) - if response.status_code == HTTPStatus.GONE: + if response.status == HTTPStatus.GONE: _LOGGER.info("Notification channel has expired") reg = self.registrations.pop(target) try: - save_json(self.registrations_json_path, self.registrations) + await self.hass.async_add_executor_job( + save_json, self.registrations_json_path, self.registrations + ) except HomeAssistantError: self.registrations[target] = reg _LOGGER.error("Error saving registration") else: _LOGGER.info("Configuration saved") - elif response.status_code >= HTTPStatus.BAD_REQUEST: + elif response.status >= HTTPStatus.BAD_REQUEST: _LOGGER.error( "There was an issue sending the notification %s: %s", - response.status_code, - response.text, + response.status, + await response.text(), ) diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index e5db0e650bdd57..62b7e35047e5e9 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -22,7 +22,7 @@ ) from homeassistant.components.number import NumberMode from homeassistant.components.sensor import ( - CONF_STATE_CLASS, + CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS, DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, STATE_CLASSES_SCHEMA, ) @@ -64,6 +64,7 @@ NumberConf, SceneConf, ) +from .dpt import get_supported_dpts from .validation import ( backwards_compatible_xknx_climate_enum_member, dpt_base_type_validator, @@ -74,6 +75,7 @@ string_type_validator, sync_state_validator, validate_number_attributes, + validate_sensor_attributes, ) @@ -143,6 +145,13 @@ def select_options_sub_validator(entity_config: OrderedDict) -> OrderedDict: return entity_config +def _sensor_attribute_sub_validator(config: dict) -> dict: + """Validate that state_class is compatible with device_class and unit_of_measurement.""" + transcoder: type[DPTBase] = DPTBase.parse_transcoder(config[CONF_TYPE]) # type: ignore[assignment] # already checked in sensor_type_validator + dpt_metadata = get_supported_dpts()[transcoder.dpt_number_str()] + return validate_sensor_attributes(dpt_metadata, config) + + ######### # EVENT ######### @@ -848,17 +857,20 @@ class SensorSchema(KNXPlatformSchema): CONF_SYNC_STATE = CONF_SYNC_STATE DEFAULT_NAME = "KNX Sensor" - ENTITY_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, - vol.Optional(CONF_ALWAYS_CALLBACK, default=False): cv.boolean, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - vol.Required(CONF_TYPE): sensor_type_validator, - vol.Required(CONF_STATE_ADDRESS): ga_list_validator, - vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, - } + ENTITY_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, + vol.Optional(CONF_ALWAYS_CALLBACK, default=False): cv.boolean, + vol.Optional(CONF_SENSOR_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Required(CONF_TYPE): sensor_type_validator, + vol.Required(CONF_STATE_ADDRESS): ga_list_validator, + vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, + } + ), + _sensor_attribute_sub_validator, ) diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index 0d5480858026df..92da35973e1561 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -213,18 +213,22 @@ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: value_type=config[CONF_TYPE], ), ) + dpt_string = self._device.sensor_value.dpt_class.dpt_number_str() + dpt_info = get_supported_dpts()[dpt_string] + if device_class := config.get(CONF_DEVICE_CLASS): self._attr_device_class = device_class else: - self._attr_device_class = try_parse_enum( - SensorDeviceClass, self._device.ha_device_class() - ) + self._attr_device_class = dpt_info["sensor_device_class"] + + self._attr_state_class = ( + config.get(CONF_STATE_CLASS) or dpt_info["sensor_state_class"] + ) + self._attr_native_unit_of_measurement = dpt_info["unit"] self._attr_force_update = config[SensorSchema.CONF_ALWAYS_CALLBACK] self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) self._attr_unique_id = str(self._device.sensor_value.group_address_state) - self._attr_native_unit_of_measurement = self._device.unit_of_measurement() - self._attr_state_class = config.get(CONF_STATE_CLASS) self._attr_extra_state_attributes = {} diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index cef993ca355a1c..c1b5d77c63f390 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -13,9 +13,7 @@ ) from homeassistant.components.sensor import ( CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS, - DEVICE_CLASS_STATE_CLASSES, DEVICE_CLASS_UNITS as SENSOR_DEVICE_CLASS_UNITS, - STATE_CLASS_UNITS, SensorDeviceClass, SensorStateClass, ) @@ -52,7 +50,7 @@ SceneConf, ) from ..dpt import get_supported_dpts -from ..validation import validate_number_attributes +from ..validation import validate_number_attributes, validate_sensor_attributes from .const import ( CONF_ALWAYS_CALLBACK, CONF_COLOR, @@ -684,62 +682,11 @@ class ConfClimateFanSpeedMode(StrEnum): ) -def _validate_sensor_attributes(config: dict) -> dict: +def _sensor_attribute_sub_validator(config: dict) -> dict: """Validate that state_class is compatible with device_class and unit_of_measurement.""" dpt = config[CONF_GA_SENSOR][CONF_DPT] dpt_metadata = get_supported_dpts()[dpt] - state_class = config.get( - CONF_SENSOR_STATE_CLASS, - dpt_metadata["sensor_state_class"], - ) - device_class = config.get( - CONF_DEVICE_CLASS, - dpt_metadata["sensor_device_class"], - ) - unit_of_measurement = config.get( - CONF_UNIT_OF_MEASUREMENT, - dpt_metadata["unit"], - ) - if ( - state_class - and device_class - and (state_classes := DEVICE_CLASS_STATE_CLASSES.get(device_class)) is not None - and state_class not in state_classes - ): - raise vol.Invalid( - f"State class '{state_class}' is not valid for device class '{device_class}'. " - f"Valid options are: {', '.join(sorted(map(str, state_classes), key=str.casefold))}", - path=[CONF_SENSOR_STATE_CLASS], - ) - if ( - device_class - and (d_c_units := SENSOR_DEVICE_CLASS_UNITS.get(device_class)) is not None - and unit_of_measurement not in d_c_units - ): - raise vol.Invalid( - f"Unit of measurement '{unit_of_measurement}' is not valid for device class '{device_class}'. " - f"Valid options are: {', '.join(sorted(map(str, d_c_units), key=str.casefold))}", - path=( - [CONF_DEVICE_CLASS] - if CONF_DEVICE_CLASS in config - else [CONF_UNIT_OF_MEASUREMENT] - ), - ) - if ( - state_class - and (s_c_units := STATE_CLASS_UNITS.get(state_class)) is not None - and unit_of_measurement not in s_c_units - ): - raise vol.Invalid( - f"Unit of measurement '{unit_of_measurement}' is not valid for state class '{state_class}'. " - f"Valid options are: {', '.join(sorted(map(str, s_c_units), key=str.casefold))}", - path=( - [CONF_SENSOR_STATE_CLASS] - if CONF_SENSOR_STATE_CLASS in config - else [CONF_UNIT_OF_MEASUREMENT] - ), - ) - return config + return validate_sensor_attributes(dpt_metadata, config) SENSOR_KNX_SCHEMA = AllSerializeFirst( @@ -788,7 +735,7 @@ def _validate_sensor_attributes(config: dict) -> dict: ), }, ), - _validate_sensor_attributes, + _sensor_attribute_sub_validator, ) KNX_SCHEMA_FOR_PLATFORM = { diff --git a/homeassistant/components/knx/validation.py b/homeassistant/components/knx/validation.py index 280ffc6b967859..f218dec0faea49 100644 --- a/homeassistant/components/knx/validation.py +++ b/homeassistant/components/knx/validation.py @@ -14,11 +14,17 @@ from homeassistant.components.number import ( DEVICE_CLASS_UNITS as NUMBER_DEVICE_CLASS_UNITS, ) +from homeassistant.components.sensor import ( + CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS, + DEVICE_CLASS_STATE_CLASSES, + DEVICE_CLASS_UNITS, + STATE_CLASS_UNITS, +) from homeassistant.const import CONF_DEVICE_CLASS, CONF_UNIT_OF_MEASUREMENT from homeassistant.helpers import config_validation as cv from .const import NumberConf -from .dpt import get_supported_dpts +from .dpt import DPTInfo, get_supported_dpts def dpt_subclass_validator(dpt_base_class: type[DPTBase]) -> Callable[[Any], str | int]: @@ -219,3 +225,65 @@ def validate_number_attributes( ) return config + + +def validate_sensor_attributes( + dpt_info: DPTInfo, config: dict[str, Any] +) -> dict[str, Any]: + """Validate that state_class is compatible with device_class and unit_of_measurement. + + Works for both, UI and YAML configuration schema since they + share same names for all tested attributes. + """ + state_class = config.get( + CONF_SENSOR_STATE_CLASS, + dpt_info["sensor_state_class"], + ) + device_class = config.get( + CONF_DEVICE_CLASS, + dpt_info["sensor_device_class"], + ) + unit_of_measurement = config.get( + CONF_UNIT_OF_MEASUREMENT, + dpt_info["unit"], + ) + if ( + state_class + and device_class + and (state_classes := DEVICE_CLASS_STATE_CLASSES.get(device_class)) is not None + and state_class not in state_classes + ): + raise vol.Invalid( + f"State class '{state_class}' is not valid for device class '{device_class}'. " + f"Valid options are: {', '.join(sorted(map(str, state_classes), key=str.casefold))}", + path=[CONF_SENSOR_STATE_CLASS], + ) + if ( + device_class + and (d_c_units := DEVICE_CLASS_UNITS.get(device_class)) is not None + and unit_of_measurement not in d_c_units + ): + raise vol.Invalid( + f"Unit of measurement '{unit_of_measurement}' is not valid for device class '{device_class}'. " + f"Valid options are: {', '.join(sorted(map(str, d_c_units), key=str.casefold))}", + path=( + [CONF_DEVICE_CLASS] + if CONF_DEVICE_CLASS in config + else [CONF_UNIT_OF_MEASUREMENT] + ), + ) + if ( + state_class + and (s_c_units := STATE_CLASS_UNITS.get(state_class)) is not None + and unit_of_measurement not in s_c_units + ): + raise vol.Invalid( + f"Unit of measurement '{unit_of_measurement}' is not valid for state class '{state_class}'. " + f"Valid options are: {', '.join(sorted(map(str, s_c_units), key=str.casefold))}", + path=( + [CONF_SENSOR_STATE_CLASS] + if CONF_SENSOR_STATE_CLASS in config + else [CONF_UNIT_OF_MEASUREMENT] + ), + ) + return config diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index 8e4910d937aa1f..15d9aec63335e1 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -9,6 +9,7 @@ Mastodon, MastodonError, MastodonNotFoundError, + MastodonUnauthorizedError, ) from homeassistant.const import ( @@ -18,7 +19,7 @@ Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify @@ -48,6 +49,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> entry, ) + except MastodonUnauthorizedError as error: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from error except MastodonError as ex: raise ConfigEntryNotReady("Failed to connect") from ex diff --git a/homeassistant/components/mastodon/config_flow.py b/homeassistant/components/mastodon/config_flow.py index 6cc82fd50f1595..963df3d2193925 100644 --- a/homeassistant/components/mastodon/config_flow.py +++ b/homeassistant/components/mastodon/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from mastodon.Mastodon import ( @@ -43,6 +44,28 @@ ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), } ) +REAUTH_SCHEMA = vol.Schema( + { + vol.Required( + CONF_ACCESS_TOKEN, + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), + } +) +STEP_RECONFIGURE_SCHEMA = vol.Schema( + { + vol.Required( + CONF_CLIENT_ID, + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), + vol.Required( + CONF_CLIENT_SECRET, + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), + vol.Required( + CONF_ACCESS_TOKEN, + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), + } +) + +EXAMPLE_URL = "https://mastodon.social" def base_url_from_url(url: str) -> str: @@ -50,18 +73,26 @@ def base_url_from_url(url: str) -> str: return str(URL(url).origin()) +def remove_email_link(account_name: str) -> str: + """Remove email link from account name.""" + + # Replaces the @ with a HTML entity to prevent mailto links. + return account_name.replace("@", "@") + + class MastodonConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" VERSION = 1 MINOR_VERSION = 2 + base_url: str + client_id: str + client_secret: str + access_token: str + def check_connection( self, - base_url: str, - client_id: str, - client_secret: str, - access_token: str, ) -> tuple[ InstanceV2 | Instance | None, Account | None, @@ -70,10 +101,10 @@ def check_connection( """Check connection to the Mastodon instance.""" try: client = create_mastodon_client( - base_url, - client_id, - client_secret, - access_token, + self.base_url, + self.client_id, + self.client_secret, + self.access_token, ) try: instance = client.instance_v2() @@ -117,12 +148,13 @@ async def async_step_user( if user_input: user_input[CONF_BASE_URL] = base_url_from_url(user_input[CONF_BASE_URL]) + self.base_url = user_input[CONF_BASE_URL] + self.client_id = user_input[CONF_CLIENT_ID] + self.client_secret = user_input[CONF_CLIENT_SECRET] + self.access_token = user_input[CONF_ACCESS_TOKEN] + instance, account, errors = await self.hass.async_add_executor_job( - self.check_connection, - user_input[CONF_BASE_URL], - user_input[CONF_CLIENT_ID], - user_input[CONF_CLIENT_SECRET], - user_input[CONF_ACCESS_TOKEN], + self.check_connection ) if not errors: @@ -137,5 +169,81 @@ async def async_step_user( return self.show_user_form( user_input, errors, - description_placeholders={"example_url": "https://mastodon.social"}, + description_placeholders={"example_url": EXAMPLE_URL}, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + self.base_url = entry_data[CONF_BASE_URL] + self.client_id = entry_data[CONF_CLIENT_ID] + self.client_secret = entry_data[CONF_CLIENT_SECRET] + self.access_token = entry_data[CONF_ACCESS_TOKEN] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + errors: dict[str, str] = {} + if user_input: + self.access_token = user_input[CONF_ACCESS_TOKEN] + instance, account, errors = await self.hass.async_add_executor_job( + self.check_connection + ) + if not errors: + name = construct_mastodon_username(instance, account) + await self.async_set_unique_id(slugify(name)) + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={CONF_ACCESS_TOKEN: user_input[CONF_ACCESS_TOKEN]}, + ) + account_name = self._get_reauth_entry().title + return self.async_show_form( + step_id="reauth_confirm", + data_schema=REAUTH_SCHEMA, + errors=errors, + description_placeholders={ + "account_name": remove_email_link(account_name), + }, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + + reconfigure_entry = self._get_reconfigure_entry() + + if user_input: + self.base_url = reconfigure_entry.data[CONF_BASE_URL] + self.client_id = user_input[CONF_CLIENT_ID] + self.client_secret = user_input[CONF_CLIENT_SECRET] + self.access_token = user_input[CONF_ACCESS_TOKEN] + instance, account, errors = await self.hass.async_add_executor_job( + self.check_connection + ) + if not errors: + name = construct_mastodon_username(instance, account) + await self.async_set_unique_id(slugify(name)) + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={ + CONF_CLIENT_ID: user_input[CONF_CLIENT_ID], + CONF_CLIENT_SECRET: user_input[CONF_CLIENT_SECRET], + CONF_ACCESS_TOKEN: user_input[CONF_ACCESS_TOKEN], + }, + ) + account_name = reconfigure_entry.title + return self.async_show_form( + step_id="reconfigure", + data_schema=STEP_RECONFIGURE_SCHEMA, + errors=errors, + description_placeholders={ + "account_name": remove_email_link(account_name), + }, ) diff --git a/homeassistant/components/mastodon/manifest.json b/homeassistant/components/mastodon/manifest.json index 157b2986c4d9a7..3105e07128e772 100644 --- a/homeassistant/components/mastodon/manifest.json +++ b/homeassistant/components/mastodon/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["mastodon"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["Mastodon.py==2.1.2"] } diff --git a/homeassistant/components/mastodon/quality_scale.yaml b/homeassistant/components/mastodon/quality_scale.yaml index ff3d4ad3db0bb4..70491d57e69625 100644 --- a/homeassistant/components/mastodon/quality_scale.yaml +++ b/homeassistant/components/mastodon/quality_scale.yaml @@ -34,10 +34,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: - status: todo - comment: | - Waiting to move to oAuth. + reauthentication-flow: done test-coverage: done # Gold devices: done @@ -67,10 +64,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: - status: todo - comment: | - Waiting to move to OAuth. + reconfiguration-flow: done repair-issues: done stale-devices: status: exempt diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index b069e09b7abdf8..9b07630a3c33ff 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -1,7 +1,11 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "wrong_account": "You have to use the same account that was used to configure the integration." }, "error": { "network_error": "The Mastodon instance was not found.", @@ -9,6 +13,28 @@ "unknown": "Unknown error occurred when connecting to the Mastodon instance." }, "step": { + "reauth_confirm": { + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "access_token": "[%key:component::mastodon::config::step::user::data_description::access_token%]" + }, + "description": "Please reauthenticate {account_name} with Mastodon." + }, + "reconfigure": { + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]", + "client_id": "[%key:component::mastodon::config::step::user::data::client_id%]", + "client_secret": "[%key:component::mastodon::config::step::user::data::client_secret%]" + }, + "data_description": { + "access_token": "[%key:component::mastodon::config::step::user::data_description::access_token%]", + "client_id": "[%key:component::mastodon::config::step::user::data_description::client_id%]", + "client_secret": "[%key:component::mastodon::config::step::user::data_description::client_secret%]" + }, + "description": "Reconfigure {account_name} with Mastodon." + }, "user": { "data": { "access_token": "[%key:common::config_flow::data::access_token%]", @@ -69,6 +95,9 @@ } }, "exceptions": { + "auth_failed": { + "message": "Authentication failed, please reauthenticate with Mastodon." + }, "idempotency_key_too_short": { "message": "Idempotency key must be at least 4 characters long." }, diff --git a/homeassistant/components/mealie/icons.json b/homeassistant/components/mealie/icons.json index 6a2afcdba3b72f..c7bc5e0772e88b 100644 --- a/homeassistant/components/mealie/icons.json +++ b/homeassistant/components/mealie/icons.json @@ -33,6 +33,9 @@ "get_recipes": { "service": "mdi:book-open-page-variant" }, + "get_shopping_list_items": { + "service": "mdi:basket" + }, "import_recipe": { "service": "mdi:map-search" }, diff --git a/homeassistant/components/mealie/services.py b/homeassistant/components/mealie/services.py index f6ba4fea1b775b..d1e4745bf5988d 100644 --- a/homeassistant/components/mealie/services.py +++ b/homeassistant/components/mealie/services.py @@ -12,6 +12,7 @@ from awesomeversion import AwesomeVersion import voluptuous as vol +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_DATE from homeassistant.core import ( HomeAssistant, @@ -64,6 +65,8 @@ } ) +SERVICE_GET_SHOPPING_LIST_ITEMS = "get_shopping_list_items" + SERVICE_IMPORT_RECIPE = "import_recipe" SERVICE_IMPORT_RECIPE_SCHEMA = vol.Schema( { @@ -321,3 +324,12 @@ def async_setup_services(hass: HomeAssistant) -> None: schema=SERVICE_SET_MEALPLAN_SCHEMA, supports_response=SupportsResponse.OPTIONAL, ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_GET_SHOPPING_LIST_ITEMS, + entity_domain=TODO_DOMAIN, + schema=None, + func="async_get_shopping_list_items", + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/mealie/services.yaml b/homeassistant/components/mealie/services.yaml index 31181c0d0917e6..6eef192dfabfe1 100644 --- a/homeassistant/components/mealie/services.yaml +++ b/homeassistant/components/mealie/services.yaml @@ -45,6 +45,12 @@ get_recipes: mode: box unit_of_measurement: recipes +get_shopping_list_items: + target: + entity: + integration: mealie + domain: todo + import_recipe: fields: config_entry_id: diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index c3b6dfd6992fea..2c337dee445d5e 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -147,6 +147,9 @@ "setup_failed": { "message": "Could not connect to the Mealie instance." }, + "shopping_list_not_found": { + "message": "Shopping list with name or ID `{shopping_list}` not found." + }, "update_failed_mealplan": { "message": "Could not fetch mealplan data." }, @@ -227,6 +230,10 @@ }, "name": "Get recipes" }, + "get_shopping_list_items": { + "description": "Gets items from a shopping list in Mealie", + "name": "Get shopping list items" + }, "import_recipe": { "description": "Imports a recipe from an URL", "fields": { diff --git a/homeassistant/components/mealie/todo.py b/homeassistant/components/mealie/todo.py index c701af2865cdf4..c504ba1e7f0595 100644 --- a/homeassistant/components/mealie/todo.py +++ b/homeassistant/components/mealie/todo.py @@ -2,7 +2,15 @@ from __future__ import annotations -from aiomealie import MealieError, MutateShoppingItem, ShoppingItem, ShoppingList +from dataclasses import asdict + +from aiomealie import ( + MealieConnectionError, + MealieError, + MutateShoppingItem, + ShoppingItem, + ShoppingList, +) from homeassistant.components.todo import ( DOMAIN as TODO_DOMAIN, @@ -11,7 +19,7 @@ TodoListEntity, TodoListEntityFeature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceResponse from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -265,3 +273,18 @@ async def async_move_todo_item( def available(self) -> bool: """Return False if shopping list no longer available.""" return super().available and self._shopping_list_id in self.coordinator.data + + async def async_get_shopping_list_items(self) -> ServiceResponse: + """Get structured shopping list items.""" + client = self.coordinator.client + try: + shopping_items = await client.get_shopping_items(self._shopping_list_id) + except MealieConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + return { + "name": self.shopping_list.name, + "items": [asdict(item) for item in shopping_items.items], + } diff --git a/homeassistant/components/mta/__init__.py b/homeassistant/components/mta/__init__.py new file mode 100644 index 00000000000000..bfa04ab9b88054 --- /dev/null +++ b/homeassistant/components/mta/__init__.py @@ -0,0 +1,28 @@ +"""The MTA New York City Transit integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN as DOMAIN +from .coordinator import MTAConfigEntry, MTADataUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: MTAConfigEntry) -> bool: + """Set up MTA from a config entry.""" + coordinator = MTADataUpdateCoordinator(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: MTAConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/mta/config_flow.py b/homeassistant/components/mta/config_flow.py new file mode 100644 index 00000000000000..b1f8d51cf43871 --- /dev/null +++ b/homeassistant/components/mta/config_flow.py @@ -0,0 +1,151 @@ +"""Config flow for MTA New York City Transit integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pymta import LINE_TO_FEED, MTAFeedError, SubwayFeed +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_LINE, CONF_STOP_ID, CONF_STOP_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class MTAConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for MTA.""" + + VERSION = 1 + MINOR_VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self.data: dict[str, Any] = {} + self.stops: dict[str, str] = {} + + 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.data[CONF_LINE] = user_input[CONF_LINE] + return await self.async_step_stop() + + lines = sorted(LINE_TO_FEED.keys()) + line_options = [SelectOptionDict(value=line, label=line) for line in lines] + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_LINE): SelectSelector( + SelectSelectorConfig( + options=line_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + errors=errors, + ) + + async def async_step_stop( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the stop step.""" + errors: dict[str, str] = {} + + if user_input is not None: + stop_id = user_input[CONF_STOP_ID] + self.data[CONF_STOP_ID] = stop_id + stop_name = self.stops.get(stop_id, stop_id) + self.data[CONF_STOP_NAME] = stop_name + + unique_id = f"{self.data[CONF_LINE]}_{stop_id}" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + # Test connection to real-time GTFS-RT feed (different from static GTFS used by get_stops) + try: + await self._async_test_connection() + except MTAFeedError: + errors["base"] = "cannot_connect" + else: + title = f"{self.data[CONF_LINE]} Line - {stop_name}" + return self.async_create_entry( + title=title, + data=self.data, + ) + + try: + self.stops = await self._async_get_stops(self.data[CONF_LINE]) + except MTAFeedError: + _LOGGER.exception("Error fetching stops for line %s", self.data[CONF_LINE]) + return self.async_abort(reason="cannot_connect") + + if not self.stops: + _LOGGER.error("No stops found for line %s", self.data[CONF_LINE]) + return self.async_abort(reason="no_stops") + + stop_options = [ + SelectOptionDict(value=stop_id, label=stop_name) + for stop_id, stop_name in sorted(self.stops.items(), key=lambda x: x[1]) + ] + + return self.async_show_form( + step_id="stop", + data_schema=vol.Schema( + { + vol.Required(CONF_STOP_ID): SelectSelector( + SelectSelectorConfig( + options=stop_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + errors=errors, + description_placeholders={"line": self.data[CONF_LINE]}, + ) + + async def _async_get_stops(self, line: str) -> dict[str, str]: + """Get stops for a line from the library.""" + feed_id = SubwayFeed.get_feed_id_for_route(line) + session = aiohttp_client.async_get_clientsession(self.hass) + + subway_feed = SubwayFeed(feed_id=feed_id, session=session) + stops_list = await subway_feed.get_stops(route_id=line) + + stops = {} + for stop in stops_list: + stop_id = stop["stop_id"] + stop_name = stop["stop_name"] + # Add direction label (stop_id always ends in N or S) + direction = stop_id[-1] + stops[stop_id] = f"{stop_name} ({direction} direction)" + + return stops + + async def _async_test_connection(self) -> None: + """Test connection to MTA feed.""" + feed_id = SubwayFeed.get_feed_id_for_route(self.data[CONF_LINE]) + session = aiohttp_client.async_get_clientsession(self.hass) + + subway_feed = SubwayFeed(feed_id=feed_id, session=session) + await subway_feed.get_arrivals( + route_id=self.data[CONF_LINE], + stop_id=self.data[CONF_STOP_ID], + max_arrivals=1, + ) diff --git a/homeassistant/components/mta/const.py b/homeassistant/components/mta/const.py new file mode 100644 index 00000000000000..4088401e8bc600 --- /dev/null +++ b/homeassistant/components/mta/const.py @@ -0,0 +1,11 @@ +"""Constants for the MTA New York City Transit integration.""" + +from datetime import timedelta + +DOMAIN = "mta" + +CONF_LINE = "line" +CONF_STOP_ID = "stop_id" +CONF_STOP_NAME = "stop_name" + +UPDATE_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/mta/coordinator.py b/homeassistant/components/mta/coordinator.py new file mode 100644 index 00000000000000..fd1edee882e467 --- /dev/null +++ b/homeassistant/components/mta/coordinator.py @@ -0,0 +1,110 @@ +"""Data update coordinator for MTA New York City Transit.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import logging + +from pymta import MTAFeedError, SubwayFeed + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import CONF_LINE, CONF_STOP_ID, DOMAIN, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class MTAArrival: + """Represents a single train arrival.""" + + arrival_time: datetime + minutes_until: int + route_id: str + destination: str + + +@dataclass +class MTAData: + """Data for MTA arrivals.""" + + arrivals: list[MTAArrival] + + +type MTAConfigEntry = ConfigEntry[MTADataUpdateCoordinator] + + +class MTADataUpdateCoordinator(DataUpdateCoordinator[MTAData]): + """Class to manage fetching MTA data.""" + + config_entry: MTAConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: MTAConfigEntry) -> None: + """Initialize.""" + self.line = config_entry.data[CONF_LINE] + self.stop_id = config_entry.data[CONF_STOP_ID] + + self.feed_id = SubwayFeed.get_feed_id_for_route(self.line) + session = async_get_clientsession(hass) + self.subway_feed = SubwayFeed(feed_id=self.feed_id, session=session) + + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) + + async def _async_update_data(self) -> MTAData: + """Fetch data from MTA.""" + _LOGGER.debug( + "Fetching data for line=%s, stop=%s, feed=%s", + self.line, + self.stop_id, + self.feed_id, + ) + + try: + library_arrivals = await self.subway_feed.get_arrivals( + route_id=self.line, + stop_id=self.stop_id, + max_arrivals=3, + ) + except MTAFeedError as err: + raise UpdateFailed(f"Error fetching MTA data: {err}") from err + + now = dt_util.now() + arrivals: list[MTAArrival] = [] + + for library_arrival in library_arrivals: + # Convert UTC arrival time to local time + arrival_time = dt_util.as_local(library_arrival.arrival_time) + + minutes_until = int((arrival_time - now).total_seconds() / 60) + + _LOGGER.debug( + "Stop %s: arrival_time=%s, minutes_until=%d, route=%s", + library_arrival.stop_id, + arrival_time, + minutes_until, + library_arrival.route_id, + ) + + arrivals.append( + MTAArrival( + arrival_time=arrival_time, + minutes_until=minutes_until, + route_id=library_arrival.route_id, + destination=library_arrival.destination, + ) + ) + + _LOGGER.debug("Returning %d arrivals", len(arrivals)) + + return MTAData(arrivals=arrivals) diff --git a/homeassistant/components/mta/manifest.json b/homeassistant/components/mta/manifest.json new file mode 100644 index 00000000000000..b1d82533df6f52 --- /dev/null +++ b/homeassistant/components/mta/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "mta", + "name": "MTA New York City Transit", + "codeowners": ["@OnFreund"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/mta", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["pymta"], + "quality_scale": "silver", + "requirements": ["py-nymta==0.3.4"] +} diff --git a/homeassistant/components/mta/quality_scale.yaml b/homeassistant/components/mta/quality_scale.yaml new file mode 100644 index 00000000000000..2cd98e9f45a1c8 --- /dev/null +++ b/homeassistant/components/mta/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register 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: Integration does not register custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not explicitly subscribe to events in async_added_to_hass. + 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: Integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No configuration options. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: No authentication required. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: No discovery. + discovery: + status: exempt + comment: No discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: + status: exempt + comment: No physical devices. + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Integration tracks a single configured stop, not dynamically discovered devices. + entity-category: + status: exempt + comment: All entities are primary entities without specific categories. + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: N/A + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repairs needed currently. + stale-devices: + status: exempt + comment: Integration tracks a single configured stop per entry, devices cannot become stale. + + # Platinum + async-dependency: todo + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/mta/sensor.py b/homeassistant/components/mta/sensor.py new file mode 100644 index 00000000000000..5f352caa7d2903 --- /dev/null +++ b/homeassistant/components/mta/sensor.py @@ -0,0 +1,147 @@ +"""Sensor platform for MTA New York City Transit.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_LINE, CONF_STOP_ID, CONF_STOP_NAME, DOMAIN +from .coordinator import MTAArrival, MTAConfigEntry, MTADataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class MTASensorEntityDescription(SensorEntityDescription): + """Describes an MTA sensor entity.""" + + arrival_index: int + value_fn: Callable[[MTAArrival], datetime | str] + + +SENSOR_DESCRIPTIONS: tuple[MTASensorEntityDescription, ...] = ( + MTASensorEntityDescription( + key="next_arrival", + translation_key="next_arrival", + device_class=SensorDeviceClass.TIMESTAMP, + arrival_index=0, + value_fn=lambda arrival: arrival.arrival_time, + ), + MTASensorEntityDescription( + key="next_arrival_route", + translation_key="next_arrival_route", + arrival_index=0, + value_fn=lambda arrival: arrival.route_id, + ), + MTASensorEntityDescription( + key="next_arrival_destination", + translation_key="next_arrival_destination", + arrival_index=0, + value_fn=lambda arrival: arrival.destination, + ), + MTASensorEntityDescription( + key="second_arrival", + translation_key="second_arrival", + device_class=SensorDeviceClass.TIMESTAMP, + arrival_index=1, + value_fn=lambda arrival: arrival.arrival_time, + ), + MTASensorEntityDescription( + key="second_arrival_route", + translation_key="second_arrival_route", + arrival_index=1, + value_fn=lambda arrival: arrival.route_id, + ), + MTASensorEntityDescription( + key="second_arrival_destination", + translation_key="second_arrival_destination", + arrival_index=1, + value_fn=lambda arrival: arrival.destination, + ), + MTASensorEntityDescription( + key="third_arrival", + translation_key="third_arrival", + device_class=SensorDeviceClass.TIMESTAMP, + arrival_index=2, + value_fn=lambda arrival: arrival.arrival_time, + ), + MTASensorEntityDescription( + key="third_arrival_route", + translation_key="third_arrival_route", + arrival_index=2, + value_fn=lambda arrival: arrival.route_id, + ), + MTASensorEntityDescription( + key="third_arrival_destination", + translation_key="third_arrival_destination", + arrival_index=2, + value_fn=lambda arrival: arrival.destination, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MTAConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up MTA sensor based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + MTASensor(coordinator, entry, description) + for description in SENSOR_DESCRIPTIONS + ) + + +class MTASensor(CoordinatorEntity[MTADataUpdateCoordinator], SensorEntity): + """Sensor for MTA train arrivals.""" + + _attr_has_entity_name = True + entity_description: MTASensorEntityDescription + + def __init__( + self, + coordinator: MTADataUpdateCoordinator, + entry: MTAConfigEntry, + description: MTASensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + self.entity_description = description + line = entry.data[CONF_LINE] + stop_id = entry.data[CONF_STOP_ID] + stop_name = entry.data.get(CONF_STOP_NAME, stop_id) + + self._attr_unique_id = f"{entry.unique_id}-{description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=f"{line} Line - {stop_name} ({stop_id})", + manufacturer="MTA", + model="Subway", + entry_type=DeviceEntryType.SERVICE, + ) + + @property + def native_value(self) -> datetime | str | None: + """Return the state of the sensor.""" + arrivals = self.coordinator.data.arrivals + if len(arrivals) <= self.entity_description.arrival_index: + return None + + return self.entity_description.value_fn( + arrivals[self.entity_description.arrival_index] + ) diff --git a/homeassistant/components/mta/strings.json b/homeassistant/components/mta/strings.json new file mode 100644 index 00000000000000..4f3b3be7d93298 --- /dev/null +++ b/homeassistant/components/mta/strings.json @@ -0,0 +1,65 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_stops": "No stops found for this line. The line may not be currently running." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "stop": { + "data": { + "stop_id": "Stop and direction" + }, + "data_description": { + "stop_id": "Select the stop and direction you want to track" + }, + "description": "Choose a stop on the {line} line. The direction is included with each stop.", + "title": "Select stop and direction" + }, + "user": { + "data": { + "line": "Line" + }, + "data_description": { + "line": "The subway line to track" + }, + "description": "Choose the subway line you want to track.", + "title": "Select subway line" + } + } + }, + "entity": { + "sensor": { + "next_arrival": { + "name": "Next arrival" + }, + "next_arrival_destination": { + "name": "Next arrival destination" + }, + "next_arrival_route": { + "name": "Next arrival route" + }, + "second_arrival": { + "name": "Second arrival" + }, + "second_arrival_destination": { + "name": "Second arrival destination" + }, + "second_arrival_route": { + "name": "Second arrival route" + }, + "third_arrival": { + "name": "Third arrival" + }, + "third_arrival_destination": { + "name": "Third arrival destination" + }, + "third_arrival_route": { + "name": "Third arrival route" + } + } + } +} diff --git a/homeassistant/components/nest/media_source.py b/homeassistant/components/nest/media_source.py index a3d2901e91153e..4c7eb87636cf25 100644 --- a/homeassistant/components/nest/media_source.py +++ b/homeassistant/components/nest/media_source.py @@ -24,6 +24,7 @@ import logging import os import pathlib +import shutil from typing import Any from google_nest_sdm.camera_traits import CameraClipPreviewTrait, CameraEventImageTrait @@ -70,7 +71,8 @@ # Buffer writes every few minutes (plus guaranteed to be written at shutdown) STORAGE_SAVE_DELAY_SECONDS = 120 # Path under config directory -MEDIA_PATH = f"{DOMAIN}/event_media" +LEGACY_MEDIA_PATH = f"{DOMAIN}/event_media" +MEDIA_CACHE_PATH = "event_media" # Size of small in-memory disk cache to avoid excessive disk reads DISK_READ_LRU_MAX_SIZE = 32 @@ -83,19 +85,39 @@ async def async_get_media_event_store( hass: HomeAssistant, subscriber: GoogleNestSubscriber ) -> EventMediaStore: """Create the disk backed EventMediaStore.""" - media_path = hass.config.path(MEDIA_PATH) - - def mkdir() -> None: - os.makedirs(media_path, exist_ok=True) - - await hass.async_add_executor_job(mkdir) + media_path = pathlib.Path(hass.config.cache_path(DOMAIN, MEDIA_CACHE_PATH)) + legacy_media_path = pathlib.Path(hass.config.path(LEGACY_MEDIA_PATH)) + await hass.async_add_executor_job( + _prepare_media_cache_dir, media_path, legacy_media_path + ) store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY, private=True) - return NestEventMediaStore(hass, subscriber, store, media_path) + return NestEventMediaStore(hass, subscriber, store, str(media_path)) + + +def _prepare_media_cache_dir( + media_path: pathlib.Path, legacy_media_path: pathlib.Path +) -> None: + """Prepare the media cache directory.""" + # Migrate media from legacy path to new path. + if legacy_media_path.exists() and not media_path.exists(): + _LOGGER.info( + "Migrating media cache directory from %s to %s", + legacy_media_path, + media_path, + ) + media_path.parent.mkdir(parents=True, exist_ok=True) + try: + shutil.move(legacy_media_path, media_path) + except OSError as error: + _LOGGER.info( + "Failed to migrate media cache directory, abandoning: %s", error + ) + media_path.mkdir(parents=True, exist_ok=True) async def async_get_transcoder(hass: HomeAssistant) -> Transcoder: """Get a nest clip transcoder.""" - media_path = hass.config.path(MEDIA_PATH) + media_path = hass.config.cache_path(DOMAIN, MEDIA_CACHE_PATH) ffmpeg_manager = get_ffmpeg_manager(hass) return Transcoder(ffmpeg_manager.binary, media_path) diff --git a/homeassistant/components/onedrive_for_business/config_flow.py b/homeassistant/components/onedrive_for_business/config_flow.py index ae1d9f6b681d46..c9b3c0473175ad 100644 --- a/homeassistant/components/onedrive_for_business/config_flow.py +++ b/homeassistant/components/onedrive_for_business/config_flow.py @@ -8,7 +8,7 @@ from onedrive_personal_sdk.clients.client import OneDriveClient from onedrive_personal_sdk.exceptions import OneDriveException -from onedrive_personal_sdk.models.items import AppRoot +from onedrive_personal_sdk.models.items import Drive import voluptuous as vol from homeassistant.config_entries import ( @@ -38,7 +38,7 @@ class OneDriveForBusinessConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): DOMAIN = DOMAIN client: OneDriveClient - approot: AppRoot + drive: Drive @property def logger(self) -> logging.Logger: @@ -102,8 +102,7 @@ async def get_access_token() -> str: ) try: - self.approot = await self.client.get_approot() - drive = await self.client.get_drive() + self.drive = await self.client.get_drive() except OneDriveException: self.logger.exception("Failed to connect to OneDrive") return self.async_abort(reason="connection_error") @@ -111,7 +110,7 @@ async def get_access_token() -> str: self.logger.exception("Unknown error") return self.async_abort(reason="unknown") - await self.async_set_unique_id(drive.id) + await self.async_set_unique_id(self.drive.id) if self.source == SOURCE_REAUTH: self._abort_if_unique_id_mismatch(reason="wrong_drive") @@ -147,9 +146,11 @@ async def async_step_select_folder( errors["base"] = "folder_creation_error" if not errors: title = ( - f"{self.approot.created_by.user.display_name}'s OneDrive" - if self.approot.created_by.user - and self.approot.created_by.user.display_name + f"{self.drive.owner.user.display_name}'s OneDrive ({self.drive.owner.user.email})" + if self.drive.owner + and self.drive.owner.user + and self.drive.owner.user.display_name + and self.drive.owner.user.email else "OneDrive" ) return self.async_create_entry( diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index d955c7a7ecf747..6abc1d52398c48 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -125,7 +125,7 @@ def __init__(self, roomba, blid) -> None: self._cap_position = self.vacuum_state.get("cap", {}).get("pose") == 1 @property - def activity(self): + def activity(self) -> VacuumActivity: """Return the state of the vacuum cleaner.""" clean_mission_status = self.vacuum_state.get("cleanMissionStatus", {}) cycle = clean_mission_status.get("cycle") @@ -213,7 +213,7 @@ async def async_start(self) -> None: else: await self.hass.async_add_executor_job(self.vacuum.send_command, "start") - async def async_stop(self, **kwargs): + async def async_stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" await self.hass.async_add_executor_job(self.vacuum.send_command, "stop") @@ -221,7 +221,7 @@ async def async_pause(self) -> None: """Pause the cleaning cycle.""" await self.hass.async_add_executor_job(self.vacuum.send_command, "pause") - async def async_return_to_base(self, **kwargs): + async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" if self.state == VacuumActivity.CLEANING: await self.async_pause() @@ -231,11 +231,16 @@ async def async_return_to_base(self, **kwargs): await asyncio.sleep(1) await self.hass.async_add_executor_job(self.vacuum.send_command, "dock") - async def async_locate(self, **kwargs): + async def async_locate(self, **kwargs: Any) -> None: """Located vacuum.""" await self.hass.async_add_executor_job(self.vacuum.send_command, "find") - async def async_send_command(self, command, params=None, **kwargs): + async def async_send_command( + self, + command: str, + params: dict[str, Any] | list[Any] | None = None, + **kwargs: Any, + ) -> None: """Send raw command.""" _LOGGER.debug("async_send_command %s (%s), %s", command, params, kwargs) await self.hass.async_add_executor_job( @@ -270,7 +275,7 @@ class RoombaVacuumCarpetBoost(RoombaVacuum): _attr_supported_features = SUPPORT_ROOMBA_CARPET_BOOST @property - def fan_speed(self): + def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" fan_speed = None carpet_boost = self.vacuum_state.get("carpetBoost") @@ -284,7 +289,7 @@ def fan_speed(self): fan_speed = FAN_SPEED_ECO return fan_speed - async def async_set_fan_speed(self, fan_speed, **kwargs): + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" if fan_speed.capitalize() in FAN_SPEEDS: fan_speed = fan_speed.capitalize() @@ -329,7 +334,7 @@ def __init__(self, roomba, blid) -> None: ] @property - def fan_speed(self): + def fan_speed(self) -> str: """Return the fan speed of the vacuum cleaner.""" # Mopping behavior and spray amount as fan speed rank_overlap = self.vacuum_state.get("rankOverlap", {}) @@ -345,7 +350,7 @@ def fan_speed(self): pad_wetness_value = pad_wetness.get("disposable") return f"{behavior}-{pad_wetness_value}" - async def async_set_fan_speed(self, fan_speed, **kwargs): + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" try: split = fan_speed.split("-", 1) diff --git a/homeassistant/components/saunum/__init__.py b/homeassistant/components/saunum/__init__.py index f0a1a9161f476f..208f0ab9861142 100644 --- a/homeassistant/components/saunum/__init__.py +++ b/homeassistant/components/saunum/__init__.py @@ -43,6 +43,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> except SaunumConnectionError as exc: raise ConfigEntryNotReady(f"Error connecting to {host}: {exc}") from exc + entry.async_on_unload(client.async_close) + coordinator = LeilSaunaCoordinator(hass, client, entry) await coordinator.async_config_entry_first_refresh() @@ -55,7 +57,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - await entry.runtime_data.client.async_close() - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/saunum/quality_scale.yaml b/homeassistant/components/saunum/quality_scale.yaml index eb0a70d673268e..fa3f1a67bf07cf 100644 --- a/homeassistant/components/saunum/quality_scale.yaml +++ b/homeassistant/components/saunum/quality_scale.yaml @@ -21,7 +21,7 @@ rules: test-before-setup: done unique-config-entry: done - # Silver tier + # Silver action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done @@ -35,7 +35,7 @@ rules: comment: Modbus TCP does not require authentication. test-coverage: done - # Gold tier + # Gold devices: done diagnostics: done discovery: diff --git a/homeassistant/components/spaceapi/quality_scale.yaml b/homeassistant/components/spaceapi/quality_scale.yaml new file mode 100644 index 00000000000000..8791627d97d47f --- /dev/null +++ b/homeassistant/components/spaceapi/quality_scale.yaml @@ -0,0 +1,120 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration has no custom service actions. + appropriate-polling: + status: exempt + comment: This integration does not poll. + brands: done + common-modules: + status: exempt + comment: This integration has no entities and no coordinator. + config-flow-test-coverage: todo + config-flow: todo + dependency-transparency: + status: exempt + comment: This integration has no dependencies. + docs-actions: + status: exempt + comment: This integration has no custom service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: This integration has no entities. + entity-unique-id: + status: exempt + comment: This integration has no entities. + has-entity-name: + status: exempt + comment: This integration has no entities. + runtime-data: todo + test-before-configure: todo + test-before-setup: todo + unique-config-entry: todo + + # Silver + action-exceptions: + status: exempt + comment: This integration has no custom service actions. + config-entry-unloading: todo + docs-configuration-parameters: todo + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: This integration has no entities. + integration-owner: done + log-when-unavailable: + status: exempt + comment: This integration has no entities. + parallel-updates: + status: exempt + comment: This integration does not poll. + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: + status: exempt + comment: This integration has no entities. + diagnostics: todo + discovery-update-info: + status: exempt + comment: This integration is a service and has no devices. + discovery: + status: exempt + comment: This integration is a service and has no devices. + docs-data-update: + status: exempt + comment: This integration does not poll. + docs-examples: + status: exempt + comment: This integration does not provide any automation + docs-known-limitations: + status: done + comment: Only SpaceAPI v13 is supported. + docs-supported-devices: + status: exempt + comment: This integration is a service and has no devices. + docs-supported-functions: + status: exempt + comment: This integration has no entities. + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: This integration is a service and has no devices. + entity-category: + status: exempt + comment: This integration has no entities. + entity-device-class: + status: exempt + comment: This integration has no entities. + entity-disabled-by-default: + status: exempt + comment: This integration has no entities. + entity-translations: + status: exempt + comment: This integration has no entities. + exception-translations: + status: exempt + comment: This integration has no custom exceptions. + icon-translations: + status: exempt + comment: This integration does not use icons. + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: This integration is a service and has no devices. + + # Platinum + async-dependency: + status: exempt + comment: This integration has no dependencies. + inject-websession: + status: exempt + comment: This integration does not use web sessions. + strict-typing: done diff --git a/homeassistant/components/tplink_omada/quality_scale.yaml b/homeassistant/components/tplink_omada/quality_scale.yaml index 0feda35f46e332..ace158c44ea87b 100644 --- a/homeassistant/components/tplink_omada/quality_scale.yaml +++ b/homeassistant/components/tplink_omada/quality_scale.yaml @@ -39,7 +39,7 @@ rules: log-when-unavailable: done parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: done diff --git a/homeassistant/components/unifi/icons.json b/homeassistant/components/unifi/icons.json index 97ff2f734a3dcd..ba94eabcc93ca6 100644 --- a/homeassistant/components/unifi/icons.json +++ b/homeassistant/components/unifi/icons.json @@ -38,6 +38,9 @@ "port_bandwidth_tx": { "default": "mdi:upload" }, + "port_link_speed": { + "default": "mdi:speedometer" + }, "wlan_clients": { "default": "mdi:account-multiple" } diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 898a59d951b8cc..7a161a9d7c2ce2 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -485,6 +485,23 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( unique_id_fn=lambda hub, obj_id: f"port_tx-{obj_id}", value_fn=lambda hub, port: port.tx_bytes_r, ), + UnifiSensorEntityDescription[Ports, Port]( + key="Port speed", + translation_key="port_link_speed", + device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, + suggested_display_precision=0, + entity_registry_enabled_default=False, + api_handler_fn=lambda api: api.ports, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + name_fn=lambda port: f"{port.name} link speed", + object_fn=lambda api, obj_id: api.ports[obj_id], + supported_fn=lambda hub, obj_id: hub.api.ports[obj_id].raw.get("speed", 0) > 0, + unique_id_fn=lambda hub, obj_id: f"port_link_speed-{obj_id}", + value_fn=lambda hub, port: port.raw.get("speed", 0), + ), UnifiSensorEntityDescription[Clients, Client]( key="Client uptime", device_class=SensorDeviceClass.TIMESTAMP, diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 084aa3e4fd7649..ef6a7c1d42ce84 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -56,6 +56,9 @@ "upgrading": "Upgrading" } }, + "port_link_speed": { + "name": "Link speed" + }, "wired_client_link_speed": { "name": "Link speed" } diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index e56fc2e54d2def..334dab34cea739 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -2,11 +2,13 @@ from __future__ import annotations +from enum import StrEnum from typing import Any -from pyvlx import ( +from pyvlx.opening_device import ( Awning, Blind, + DualRollerShutter, GarageDoor, Gate, OpeningDevice, @@ -43,6 +45,23 @@ async def async_setup_entry( for node in pyvlx.nodes: if isinstance(node, Blind): entities.append(VeluxBlind(node, config_entry.entry_id)) + elif isinstance(node, DualRollerShutter): + # add three entities, one for each part and the "dual" control + entities.append( + VeluxDualRollerShutter( + node, config_entry.entry_id, VeluxDualRollerPart.DUAL + ) + ) + entities.append( + VeluxDualRollerShutter( + node, config_entry.entry_id, VeluxDualRollerPart.UPPER + ) + ) + entities.append( + VeluxDualRollerShutter( + node, config_entry.entry_id, VeluxDualRollerPart.LOWER + ) + ) elif isinstance(node, OpeningDevice): entities.append(VeluxCover(node, config_entry.entry_id)) @@ -54,9 +73,6 @@ class VeluxCover(VeluxEntity, CoverEntity): node: OpeningDevice - # Do not name the "main" feature of the device (position control) - _attr_name = None - # Features common to all covers _attr_supported_features = ( CoverEntityFeature.OPEN @@ -125,6 +141,72 @@ async def async_stop_cover(self, **kwargs: Any) -> None: await self.node.stop(wait_for_completion=False) +class VeluxDualRollerPart(StrEnum): + """Enum for the parts of a dual roller shutter.""" + + UPPER = "upper" + LOWER = "lower" + DUAL = "dual" + + +class VeluxDualRollerShutter(VeluxCover): + """Representation of a Velux dual roller shutter cover.""" + + node: DualRollerShutter + _attr_device_class = CoverDeviceClass.SHUTTER + + def __init__( + self, node: DualRollerShutter, config_entry_id: str, part: VeluxDualRollerPart + ) -> None: + """Initialize VeluxDualRollerShutter.""" + super().__init__(node, config_entry_id) + if part == VeluxDualRollerPart.DUAL: + self._attr_name = None + else: + self._attr_unique_id = f"{self._attr_unique_id}_{part}" + self._attr_translation_key = f"dual_roller_shutter_{part}" + self.part = part + + @property + def current_cover_position(self) -> int: + """Return the current position of the cover.""" + if self.part == VeluxDualRollerPart.UPPER: + return 100 - self.node.position_upper_curtain.position_percent + if self.part == VeluxDualRollerPart.LOWER: + return 100 - self.node.position_lower_curtain.position_percent + return 100 - self.node.position.position_percent + + @property + def is_closed(self) -> bool: + """Return if the cover is closed.""" + if self.part == VeluxDualRollerPart.UPPER: + return self.node.position_upper_curtain.closed + if self.part == VeluxDualRollerPart.LOWER: + return self.node.position_lower_curtain.closed + return self.node.position.closed + + @wrap_pyvlx_call_exceptions + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self.node.close(curtain=self.part, wait_for_completion=False) + + @wrap_pyvlx_call_exceptions + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.node.open(curtain=self.part, wait_for_completion=False) + + @wrap_pyvlx_call_exceptions + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + position_percent = 100 - kwargs[ATTR_POSITION] + + await self.node.set_position( + Position(position_percent=position_percent), + curtain=self.part, + wait_for_completion=False, + ) + + class VeluxBlind(VeluxCover): """Representation of a Velux blind cover.""" diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index fbd0d94e6fa566..6e9247aeed8fe6 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -14,5 +14,5 @@ "iot_class": "local_polling", "loggers": ["pyvlx"], "quality_scale": "silver", - "requirements": ["pyvlx==0.2.29"] + "requirements": ["pyvlx==0.2.30"] } diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json index 13abb8a0f78cb5..a52fb0a245c9d9 100644 --- a/homeassistant/components/velux/strings.json +++ b/homeassistant/components/velux/strings.json @@ -45,6 +45,14 @@ "rain_sensor": { "name": "Rain sensor" } + }, + "cover": { + "dual_roller_shutter_lower": { + "name": "Lower shutter" + }, + "dual_roller_shutter_upper": { + "name": "Upper shutter" + } } }, "exceptions": { @@ -60,8 +68,8 @@ }, "issues": { "deprecated_reboot_service": { - "description": "The `velux.reboot_gateway` service is deprecated and will be removed in Home Assistant 2026.6.0. Please use the 'Restart' button entity instead. You can find this button in the device page for your KLF 200 Gateway or by searching for 'restart' in your entity list.", - "title": "Velux reboot service is deprecated" + "description": "The `velux.reboot_gateway` action is deprecated and will be removed in Home Assistant 2026.6.0. Please use the 'Restart' button entity instead. You can find this button in the device page for your KLF 200 Gateway or by searching for 'restart' in your entity list.", + "title": "Velux 'Reboot gateway' action deprecated" } }, "services": { diff --git a/homeassistant/components/watts/config_flow.py b/homeassistant/components/watts/config_flow.py index c71e67528aa2a2..620d376cfec41a 100644 --- a/homeassistant/components/watts/config_flow.py +++ b/homeassistant/components/watts/config_flow.py @@ -1,11 +1,12 @@ """Config flow for Watts Vision integration.""" +from collections.abc import Mapping import logging from typing import Any from visionpluspython.auth import WattsVisionAuth -from homeassistant.config_entries import ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.helpers import config_entry_oauth2_flow from .const import DOMAIN, OAUTH2_SCOPES @@ -32,6 +33,25 @@ def extra_authorize_data(self) -> dict[str, Any]: "prompt": "consent", } + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauthentication upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + + return await self.async_step_pick_implementation( + user_input={ + "implementation": self._get_reauth_entry().data["auth_implementation"] + } + ) + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for the OAuth2 flow.""" @@ -42,6 +62,15 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu return self.async_abort(reason="invalid_token") await self.async_set_unique_id(user_id) + + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch") + + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data=data, + ) + self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/homeassistant/components/watts/manifest.json b/homeassistant/components/watts/manifest.json index 71fac5e6a69350..25135798cb2b87 100644 --- a/homeassistant/components/watts/manifest.json +++ b/homeassistant/components/watts/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["application_credentials", "cloud"], "documentation": "https://www.home-assistant.io/integrations/watts", "iot_class": "cloud_polling", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["visionpluspython==1.0.2"] } diff --git a/homeassistant/components/watts/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml index 152dcbbd3f5c53..812a904bc0769f 100644 --- a/homeassistant/components/watts/quality_scale.yaml +++ b/homeassistant/components/watts/quality_scale.yaml @@ -30,7 +30,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/watts/strings.json b/homeassistant/components/watts/strings.json index d7a38341abe148..4524f670e731f5 100644 --- a/homeassistant/components/watts/strings.json +++ b/homeassistant/components/watts/strings.json @@ -12,6 +12,8 @@ "oauth_implementation_unavailable": "[%key:common::config_flow::abort::oauth2_implementation_unavailable%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "reauth_account_mismatch": "The authenticated account does not match the account that needed re-authentication", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" }, "create_entry": { @@ -20,6 +22,10 @@ "step": { "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "description": "The Watts Vision + integration needs to re-authenticate your account", + "title": "[%key:common::config_flow::title::reauth%]" } } }, diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index 326008ae1af4bf..977479a8b1906b 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/wled", "integration_type": "device", "iot_class": "local_push", + "quality_scale": "platinum", "requirements": ["wled==0.21.0"], "zeroconf": ["_wled._tcp.local."] } diff --git a/homeassistant/components/wled/quality_scale.yaml b/homeassistant/components/wled/quality_scale.yaml index 11a59bcc6d97f9..c3185f05dce514 100644 --- a/homeassistant/components/wled/quality_scale.yaml +++ b/homeassistant/components/wled/quality_scale.yaml @@ -24,10 +24,11 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done - docs-installation-parameters: todo + docs-installation-parameters: done + entity-unavailable: done integration-owner: done log-when-unavailable: done parallel-updates: done @@ -41,25 +42,19 @@ rules: diagnostics: done discovery-update-info: done discovery: done - docs-data-update: todo + docs-data-update: done docs-examples: done - docs-known-limitations: - status: todo - comment: | - Analog RGBCCT Strip are poor supported by HA. - See: https://github.com/home-assistant/core/issues/123614 - docs-supported-devices: todo + docs-known-limitations: done + docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo - docs-use-cases: todo + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: | This integration has a fixed single device. entity-category: done - entity-device-class: - status: todo - comment: Led count could receive unit of measurement + entity-device-class: done entity-disabled-by-default: done entity-translations: done exception-translations: done diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 9719406472e638..aa4303c6709413 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -89,7 +89,8 @@ "name": "Free memory" }, "info_leds_count": { - "name": "LED count" + "name": "LED count", + "unit_of_measurement": "LEDs" }, "info_leds_max_power": { "name": "Max current" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 463fd28ec96c9a..04902a57f0252e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -444,6 +444,7 @@ "motionmount", "mpd", "mqtt", + "mta", "mullvad", "music_assistant", "mutesync", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a18fbe6822c9e5..e111bae54b2e35 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4348,6 +4348,12 @@ } } }, + "mta": { + "name": "MTA New York City Transit", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "mullvad": { "name": "Mullvad VPN", "integration_type": "service", diff --git a/mypy.ini b/mypy.ini index a98121fc0d8f1b..5a9058326c6dc6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4716,6 +4716,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.spaceapi.*] +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.speedtestdotnet.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 47416da20ccdeb..278023c4f3cd1d 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2751,10 +2751,12 @@ class ClassTypeHintMatch: TypeHintMatch( function_name="auto_update", return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="installed_version", return_type=["str", None], + mandatory=True, ), TypeHintMatch( function_name="device_class", @@ -2764,30 +2766,37 @@ class ClassTypeHintMatch: TypeHintMatch( function_name="in_progress", return_type=["bool", None], + mandatory=True, ), TypeHintMatch( function_name="latest_version", return_type=["str", None], + mandatory=True, ), TypeHintMatch( function_name="release_summary", return_type=["str", None], + mandatory=True, ), TypeHintMatch( function_name="release_url", return_type=["str", None], + mandatory=True, ), TypeHintMatch( function_name="supported_features", return_type="UpdateEntityFeature", + mandatory=True, ), TypeHintMatch( function_name="title", return_type=["str", None], + mandatory=True, ), TypeHintMatch( function_name="update_percentage", return_type=["int", "float", None], + mandatory=True, ), TypeHintMatch( function_name="install", @@ -2795,11 +2804,13 @@ class ClassTypeHintMatch: kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="release_notes", return_type=["str", None], has_async_counterpart=True, + mandatory=True, ), ], ), @@ -2819,64 +2830,77 @@ class ClassTypeHintMatch: TypeHintMatch( function_name="state", return_type=["str", None], + mandatory=True, ), TypeHintMatch( function_name="activity", return_type=["VacuumActivity", None], + mandatory=True, ), TypeHintMatch( function_name="battery_level", return_type=["int", None], + mandatory=True, ), TypeHintMatch( function_name="battery_icon", return_type="str", + mandatory=True, ), TypeHintMatch( function_name="fan_speed", return_type=["str", None], + mandatory=True, ), TypeHintMatch( function_name="fan_speed_list", return_type="list[str]", + mandatory=True, ), TypeHintMatch( function_name="supported_features", return_type="VacuumEntityFeature", + mandatory=True, ), TypeHintMatch( function_name="stop", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="start", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="pause", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="return_to_base", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="clean_spot", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="locate", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="set_fan_speed", @@ -2886,6 +2910,7 @@ class ClassTypeHintMatch: kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="send_command", @@ -2896,6 +2921,7 @@ class ClassTypeHintMatch: kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), ], ), @@ -2915,72 +2941,88 @@ class ClassTypeHintMatch: TypeHintMatch( function_name="current_operation", return_type=["str", None], + mandatory=True, ), TypeHintMatch( function_name="current_temperature", return_type=["float", None], + mandatory=True, ), TypeHintMatch( function_name="is_away_mode_on", return_type=["bool", None], + mandatory=True, ), TypeHintMatch( function_name="max_temp", return_type="float", + mandatory=True, ), TypeHintMatch( function_name="min_temp", return_type="float", + mandatory=True, ), TypeHintMatch( function_name="operation_list", return_type=["list[str]", None], + mandatory=True, ), TypeHintMatch( function_name="precision", return_type="float", + mandatory=True, ), TypeHintMatch( function_name="supported_features", return_type="WaterHeaterEntityFeature", + mandatory=True, ), TypeHintMatch( function_name="target_temperature", return_type=["float", None], + mandatory=True, ), TypeHintMatch( function_name="target_temperature_high", return_type=["float", None], + mandatory=True, ), TypeHintMatch( function_name="target_temperature_low", return_type=["float", None], + mandatory=True, ), TypeHintMatch( function_name="temperature_unit", return_type="str", + mandatory=True, ), TypeHintMatch( function_name="set_temperature", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="set_operation_mode", arg_types={1: "str"}, return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="turn_away_mode_on", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="turn_away_mode_off", return_type=None, has_async_counterpart=True, + mandatory=True, ), ], ), diff --git a/requirements_all.txt b/requirements_all.txt index 068635d57f4f1f..03c4c4b395b5dd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1851,6 +1851,9 @@ py-nextbusnext==2.3.0 # homeassistant.components.nightscout py-nightscout==1.2.2 +# homeassistant.components.mta +py-nymta==0.3.4 + # homeassistant.components.schluter py-schluter==0.1.7 @@ -2693,7 +2696,7 @@ pyvesync==3.4.1 pyvizio==0.1.61 # homeassistant.components.velux -pyvlx==0.2.29 +pyvlx==0.2.30 # homeassistant.components.volumio pyvolumio==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f79d9c975eeb0e..2fd188a6df547f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1600,6 +1600,9 @@ py-nextbusnext==2.3.0 # homeassistant.components.nightscout py-nightscout==1.2.2 +# homeassistant.components.mta +py-nymta==0.3.4 + # homeassistant.components.ecovacs py-sucks==0.9.11 @@ -2274,7 +2277,7 @@ pyvesync==3.4.1 pyvizio==0.1.61 # homeassistant.components.velux -pyvlx==0.2.29 +pyvlx==0.2.30 # homeassistant.components.volumio pyvolumio==0.1.5 diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index f3d7acaf653603..d2bf1422e5ab7d 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -125,7 +125,6 @@ class Rule: "adax", "adguard", "ads", - "advantage_air", "aemet", "aftership", "agent_dvr", @@ -892,7 +891,6 @@ class Rule: "songpal", "sony_projector", "soundtouch", - "spaceapi", "spc", "speedtestdotnet", "spider", @@ -1404,7 +1402,6 @@ class Rule: "geofency", "geonetnz_quakes", "geonetnz_volcano", - "gios", "github", "gitlab_ci", "gitter", @@ -2065,7 +2062,6 @@ class Rule: "wirelesstag", "withings", "wiz", - "wled", "wmspro", "wolflink", "workday", diff --git a/tests/common.py b/tests/common.py index efda5a6a1c3d62..2e1a9f3fe14ee2 100644 --- a/tests/common.py +++ b/tests/common.py @@ -560,7 +560,11 @@ def _async_fire_time_changed( def get_fixture_path(filename: str, integration: str | None = None) -> pathlib.Path: """Get path of fixture.""" - if integration is None and "/" in filename and not filename.startswith("helpers/"): + if ( + integration is None + and "/" in filename + and not filename.startswith(("core/", "helpers/")) + ): integration, filename = filename.split("/", 1) if integration is None: diff --git a/tests/components/control4/conftest.py b/tests/components/control4/conftest.py index 671588c0d943b5..38300b88f0aa68 100644 --- a/tests/components/control4/conftest.py +++ b/tests/components/control4/conftest.py @@ -129,6 +129,7 @@ def mock_climate_variables() -> dict: "HEAT_SETPOINT_F": 68.0, "FAN_MODE": "Auto", "FAN_MODES_LIST": "Auto,On,Circulate", + "SCALE": "FAHRENHEIT", } } @@ -160,6 +161,8 @@ def mock_c4_climate() -> Generator[MagicMock]: mock_instance.setHeatSetpointF = AsyncMock() mock_instance.setCoolSetpointF = AsyncMock() mock_instance.setFanMode = AsyncMock() + mock_instance.setHeatSetpointC = AsyncMock() + mock_instance.setCoolSetpointC = AsyncMock() yield mock_instance diff --git a/tests/components/control4/test_climate.py b/tests/components/control4/test_climate.py index 97d432d55b3316..50015672e65e89 100644 --- a/tests/components/control4/test_climate.py +++ b/tests/components/control4/test_climate.py @@ -39,8 +39,9 @@ def _make_climate_data( humidity: int = 50, cool_setpoint: float = 75.0, heat_setpoint: float = 68.0, + scale: str = "FAHRENHEIT", ) -> dict[int, dict[str, Any]]: - """Build mock climate variable data for item ID 123.""" + """Build mock climate variable data for item ID 123 (Fahrenheit).""" return { 123: { "HVAC_STATE": hvac_state, @@ -49,6 +50,7 @@ def _make_climate_data( "HUMIDITY": humidity, "COOL_SETPOINT_F": cool_setpoint, "HEAT_SETPOINT_F": heat_setpoint, + "SCALE": scale, } } @@ -344,6 +346,7 @@ async def test_climate_not_created_when_no_initial_data( # Missing TEMPERATURE_F and HUMIDITY "COOL_SETPOINT_F": 75.0, "HEAT_SETPOINT_F": 68.0, + "SCALE": "FAHRENHEIT", } } ], @@ -444,6 +447,7 @@ async def test_set_fan_mode( "HUMIDITY": 50, "COOL_SETPOINT_F": 75.0, "HEAT_SETPOINT_F": 68.0, + "SCALE": "FAHRENHEIT", # No FAN_MODE or FAN_MODES_LIST } } @@ -467,3 +471,55 @@ async def test_fan_mode_not_supported( assert not ( state.attributes.get("supported_features") & ClimateEntityFeature.FAN_MODE ) + + +# Temperature unit tests - verify correct API methods are called based on SCALE + + +@pytest.mark.parametrize( + ("mock_climate_variables", "expected_method", "unexpected_method"), + [ + pytest.param( + _make_climate_data(hvac_state="Off", hvac_mode="Heat"), + "setHeatSetpointF", + "setHeatSetpointC", + id="fahrenheit_heat_calls_F_not_C", + ), + pytest.param( + _make_climate_data(hvac_state="Cool", hvac_mode="Cool"), + "setCoolSetpointF", + "setCoolSetpointC", + id="fahrenheit_cool_calls_F_not_C", + ), + ], +) +@pytest.mark.usefixtures( + "mock_c4_account", + "mock_c4_director", + "mock_climate_update_variables", + "init_integration", +) +async def test_set_temperature_calls_correct_api( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_c4_climate: MagicMock, + expected_method: str, + unexpected_method: str, +) -> None: + """Test setting temperature calls correct API method based on SCALE. + + Verifies that when setting temperature: + - The correct method for the scale is called + - The wrong scale's method is NOT called + """ + # Reset mock to clear any calls from previous parametrized test runs + mock_c4_climate.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 70.0}, + blocking=True, + ) + getattr(mock_c4_climate, expected_method).assert_called_once_with(70.0) + getattr(mock_c4_climate, unexpected_method).assert_not_called() diff --git a/tests/components/daikin/conftest.py b/tests/components/daikin/conftest.py new file mode 100644 index 00000000000000..f3ef384add0d95 --- /dev/null +++ b/tests/components/daikin/conftest.py @@ -0,0 +1,109 @@ +"""Fixtures for Daikin tests.""" + +from __future__ import annotations + +from collections.abc import Callable, Generator +import re +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch +import urllib.parse + +import pytest + +type ZoneDefinition = list[str | int] +type ZoneDevice = MagicMock + + +def _decode_zone_values(value: str) -> list[str]: + """Decode a semicolon separated list into zone values.""" + return re.findall(r"[^;]+", urllib.parse.unquote(value)) + + +def configure_zone_device( + zone_device: ZoneDevice, + *, + zones: list[ZoneDefinition], + target_temperature: float | None = 22, + mode: str = "hot", + heating_values: str | None = None, + cooling_values: str | None = None, +) -> None: + """Configure a mocked zone-capable Daikin device for a test.""" + zone_device.target_temperature = target_temperature + zone_device.zones = zones + zone_device._mode = mode + + encoded_zone_temperatures = ";".join(str(zone[2]) for zone in zones) + zone_device.values = { + "name": "Daikin Test", + "model": "TESTMODEL", + "ver": "1_0_0", + "zone_name": ";".join(str(zone[0]) for zone in zones), + "zone_onoff": ";".join(str(zone[1]) for zone in zones), + "lztemp_h": ( + encoded_zone_temperatures if heating_values is None else heating_values + ), + "lztemp_c": ( + encoded_zone_temperatures if cooling_values is None else cooling_values + ), + } + + +@pytest.fixture +def zone_device() -> Generator[ZoneDevice]: + """Return a mocked zone-capable Daikin device and patch its factory.""" + device = MagicMock(name="DaikinZoneDevice") + device.mac = "001122334455" + device.fan_rate = [] + device.swing_modes = [] + device.support_away_mode = False + device.support_advanced_modes = False + device.support_fan_rate = False + device.support_swing_mode = False + device.support_outside_temperature = False + device.support_energy_consumption = False + device.support_humidity = False + device.support_compressor_frequency = False + device.compressor_frequency = 0 + device.inside_temperature = 21.0 + device.outside_temperature = 13.0 + device.humidity = 40 + device.current_total_power_consumption = 0.0 + device.last_hour_cool_energy_consumption = 0.0 + device.last_hour_heat_energy_consumption = 0.0 + device.today_energy_consumption = 0.0 + device.today_total_energy_consumption = 0.0 + + configure_zone_device(device, zones=[["Living", "1", 22]]) + + def _represent(key: str) -> tuple[None, list[str] | str]: + dynamic_values: dict[str, Callable[[], list[str] | str]] = { + "lztemp_h": lambda: _decode_zone_values(device.values["lztemp_h"]), + "lztemp_c": lambda: _decode_zone_values(device.values["lztemp_c"]), + "mode": lambda: device._mode, + "f_rate": lambda: "auto", + "f_dir": lambda: "3d", + "en_hol": lambda: "off", + "adv": lambda: "", + "htemp": lambda: str(device.inside_temperature), + "otemp": lambda: str(device.outside_temperature), + } + return (None, dynamic_values.get(key, lambda: "")()) + + async def _set(values: dict[str, Any]) -> None: + if mode := values.get("mode"): + device._mode = mode + + device.represent = MagicMock(side_effect=_represent) + device.update_status = AsyncMock() + device.set = AsyncMock(side_effect=_set) + device.set_zone = AsyncMock() + device.set_holiday = AsyncMock() + device.set_advanced_mode = AsyncMock() + device.set_streamer = AsyncMock() + + with patch( + "homeassistant.components.daikin.DaikinFactory", + new=AsyncMock(return_value=device), + ): + yield device diff --git a/tests/components/daikin/test_zone_climate.py b/tests/components/daikin/test_zone_climate.py new file mode 100644 index 00000000000000..168d0bd5f5b1f2 --- /dev/null +++ b/tests/components/daikin/test_zone_climate.py @@ -0,0 +1,353 @@ +"""Tests for Daikin zone climate entities.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.climate import ( + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACAction, + HVACMode, +) +from homeassistant.components.daikin.const import DOMAIN, KEY_MAC +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + CONF_HOST, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_component import async_update_entity + +from .conftest import ZoneDevice, configure_zone_device + +from tests.common import MockConfigEntry + +HOST = "127.0.0.1" + + +async def _async_setup_daikin( + hass: HomeAssistant, zone_device: ZoneDevice +) -> MockConfigEntry: + """Set up a Daikin config entry with a mocked library device.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=zone_device.mac, + data={CONF_HOST: HOST, KEY_MAC: zone_device.mac}, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +def _zone_entity_id( + entity_registry: er.EntityRegistry, zone_device: ZoneDevice, zone_id: int +) -> str | None: + """Return the entity id for a zone climate unique id.""" + return entity_registry.async_get_entity_id( + CLIMATE_DOMAIN, + DOMAIN, + f"{zone_device.mac}-zone{zone_id}-temperature", + ) + + +async def _async_set_zone_temperature( + hass: HomeAssistant, entity_id: str, temperature: float +) -> None: + """Call `climate.set_temperature` for a zone climate.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TEMPERATURE: temperature, + }, + blocking=True, + ) + + +async def test_setup_entry_adds_zone_climates( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Configured zones create zone climate entities.""" + configure_zone_device( + zone_device, zones=[["-", "0", 0], ["Living", "1", 22], ["Office", "1", 21]] + ) + + await _async_setup_daikin(hass, zone_device) + + assert _zone_entity_id(entity_registry, zone_device, 0) is None + assert _zone_entity_id(entity_registry, zone_device, 1) is not None + assert _zone_entity_id(entity_registry, zone_device, 2) is not None + + +async def test_setup_entry_skips_zone_climates_without_support( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Missing zone temperature lists skip zone climate entities.""" + configure_zone_device(zone_device, zones=[["Living", "1", 22]]) + zone_device.values["lztemp_h"] = "" + zone_device.values["lztemp_c"] = "" + + await _async_setup_daikin(hass, zone_device) + + assert _zone_entity_id(entity_registry, zone_device, 0) is None + + +@pytest.mark.parametrize( + ("mode", "expected_zone_key"), + [("hot", "lztemp_h"), ("cool", "lztemp_c")], +) +async def test_zone_climate_sets_temperature_for_active_mode( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, + mode: str, + expected_zone_key: str, +) -> None: + """Setting temperature updates the active mode zone value.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22], ["Office", "1", 21]], + mode=mode, + ) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + + await _async_set_zone_temperature(hass, entity_id, 23) + + zone_device.set_zone.assert_awaited_once_with(0, expected_zone_key, "23") + + +async def test_zone_climate_rejects_out_of_range_temperature( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Service validation rejects values outside the allowed range.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22]], + target_temperature=22, + ) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + + with pytest.raises(ServiceValidationError) as err: + await _async_set_zone_temperature(hass, entity_id, 30) + + assert err.value.translation_key == "temp_out_of_range" + + +async def test_zone_climate_unavailable_without_target_temperature( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Zones are unavailable if system target temperature is missing.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22]], + target_temperature=None, + ) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_zone_climate_zone_inactive_after_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Inactive zones raise a translated error during service calls.""" + configure_zone_device(zone_device, zones=[["Living", "1", 22]]) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + zone_device.zones[0][0] = "-" + + with pytest.raises(HomeAssistantError) as err: + await _async_set_zone_temperature(hass, entity_id, 21) + + assert err.value.translation_key == "zone_inactive" + + +async def test_zone_climate_zone_missing_after_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Missing zones raise a translated error during service calls.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22], ["Office", "1", 22]], + ) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 1) + assert entity_id is not None + zone_device.zones = [["Living", "1", 22]] + + with pytest.raises(HomeAssistantError) as err: + await _async_set_zone_temperature(hass, entity_id, 21) + + assert err.value.translation_key == "zone_missing" + + +async def test_zone_climate_parameters_unavailable( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Missing zone parameter lists make the zone entity unavailable.""" + configure_zone_device(zone_device, zones=[["Living", "1", 22]]) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + zone_device.values["lztemp_h"] = "" + zone_device.values["lztemp_c"] = "" + + await async_update_entity(hass, entity_id) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_zone_climate_hvac_modes_read_only( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Changing HVAC mode through a zone climate is blocked.""" + configure_zone_device(zone_device, zones=[["Living", "1", 22]]) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) + + assert err.value.translation_key == "zone_hvac_read_only" + + +async def test_zone_climate_set_temperature_requires_heat_or_cool( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Setting temperature in unsupported modes raises a translated error.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22]], + mode="auto", + ) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + + with pytest.raises(HomeAssistantError) as err: + await _async_set_zone_temperature(hass, entity_id, 21) + + assert err.value.translation_key == "zone_hvac_mode_unsupported" + + +async def test_zone_climate_properties( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Zone climate exposes expected state attributes.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22]], + target_temperature=24, + mode="cool", + heating_values="20", + cooling_values="18", + ) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == HVACMode.COOL + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_TEMPERATURE] == 18.0 + assert state.attributes[ATTR_MIN_TEMP] == 22.0 + assert state.attributes[ATTR_MAX_TEMP] == 26.0 + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.COOL] + assert state.attributes["zone_id"] == 0 + + +async def test_zone_climate_target_temperature_inactive_mode( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """In non-heating/cooling modes, zone target temperature is None.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22]], + mode="auto", + heating_values="bad", + cooling_values="19", + ) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == HVACMode.HEAT_COOL + assert state.attributes[ATTR_TEMPERATURE] is None + + +async def test_zone_climate_set_zone_failed( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Service call surfaces backend zone update errors.""" + configure_zone_device(zone_device, zones=[["Living", "1", 22]]) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + zone_device.set_zone = AsyncMock(side_effect=NotImplementedError) + + with pytest.raises(HomeAssistantError) as err: + await _async_set_zone_temperature(hass, entity_id, 21) + + assert err.value.translation_key == "zone_set_failed" diff --git a/tests/components/devolo_home_network/test_binary_sensor.py b/tests/components/devolo_home_network/test_binary_sensor.py index e793c509b1367a..de994ca90b7287 100644 --- a/tests/components/devolo_home_network/test_binary_sensor.py +++ b/tests/components/devolo_home_network/test_binary_sensor.py @@ -7,7 +7,7 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.binary_sensor import DOMAIN as PLATFORM +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.devolo_home_network.const import LONG_UPDATE_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_ON, STATE_UNAVAILABLE @@ -34,7 +34,7 @@ async def test_binary_sensor_setup( assert entry.state is ConfigEntryState.LOADED assert entity_registry.async_get( - f"{PLATFORM}.{device_name}_connected_to_router" + f"{BINARY_SENSOR_DOMAIN}.{device_name}_connected_to_router" ).disabled @@ -49,13 +49,13 @@ async def test_update_attached_to_router( """Test state change of a attached_to_router binary sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_connected_to_router" + entity_id = f"{BINARY_SENSOR_DOMAIN}.{device_name}_connected_to_router" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(state_key) == snapshot - assert entity_registry.async_get(state_key) == snapshot + assert hass.states.get(entity_id) == snapshot + assert entity_registry.async_get(entity_id) == snapshot # Emulate device failure mock_device.plcnet.async_get_network_overview = AsyncMock( @@ -65,7 +65,7 @@ async def test_update_attached_to_router( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_UNAVAILABLE @@ -77,6 +77,6 @@ async def test_update_attached_to_router( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_ON diff --git a/tests/components/devolo_home_network/test_button.py b/tests/components/devolo_home_network/test_button.py index 8a8028454ea511..cf68d1887c5993 100644 --- a/tests/components/devolo_home_network/test_button.py +++ b/tests/components/devolo_home_network/test_button.py @@ -6,7 +6,7 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.button import DOMAIN as PLATFORM, SERVICE_PRESS +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.devolo_home_network.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID @@ -31,15 +31,17 @@ async def test_button_setup( assert entry.state is ConfigEntryState.LOADED assert not entity_registry.async_get( - f"{PLATFORM}.{device_name}_identify_device_with_a_blinking_led" + f"{BUTTON_DOMAIN}.{device_name}_identify_device_with_a_blinking_led" ).disabled assert not entity_registry.async_get( - f"{PLATFORM}.{device_name}_start_plc_pairing" + f"{BUTTON_DOMAIN}.{device_name}_start_plc_pairing" ).disabled assert not entity_registry.async_get( - f"{PLATFORM}.{device_name}_restart_device" + f"{BUTTON_DOMAIN}.{device_name}_restart_device" + ).disabled + assert not entity_registry.async_get( + f"{BUTTON_DOMAIN}.{device_name}_start_wps" ).disabled - assert not entity_registry.async_get(f"{PLATFORM}.{device_name}_start_wps").disabled @pytest.mark.parametrize( @@ -80,23 +82,23 @@ async def test_button( """Test a button.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_{name}" + entity_id = f"{BUTTON_DOMAIN}.{device_name}_{name}" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(state_key) == snapshot - assert entity_registry.async_get(state_key) == snapshot + assert hass.states.get(entity_id) == snapshot + assert entity_registry.async_get(entity_id) == snapshot # Emulate button press await hass.services.async_call( - PLATFORM, + BUTTON_DOMAIN, SERVICE_PRESS, - {ATTR_ENTITY_ID: state_key}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state.state == "2023-01-13T12:00:00+00:00" api = getattr(mock_device, api_name) assert getattr(api, trigger_method).call_count == 1 @@ -106,9 +108,9 @@ async def test_button( getattr(api, trigger_method).side_effect = DeviceUnavailable with pytest.raises(HomeAssistantError): await hass.services.async_call( - PLATFORM, + BUTTON_DOMAIN, SERVICE_PRESS, - {ATTR_ENTITY_ID: state_key}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) @@ -117,7 +119,7 @@ async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None """Test setting unautherized triggers the reauth flow.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_start_wps" + entity_id = f"{BUTTON_DOMAIN}.{device_name}_start_wps" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -126,9 +128,9 @@ async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None with pytest.raises(HomeAssistantError): await hass.services.async_call( - PLATFORM, + BUTTON_DOMAIN, SERVICE_PRESS, - {ATTR_ENTITY_ID: state_key}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) diff --git a/tests/components/devolo_home_network/test_device_tracker.py b/tests/components/devolo_home_network/test_device_tracker.py index cb92b8bc3d90b0..8358b2d5d56656 100644 --- a/tests/components/devolo_home_network/test_device_tracker.py +++ b/tests/components/devolo_home_network/test_device_tracker.py @@ -7,7 +7,7 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.device_tracker import DOMAIN as PLATFORM +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN from homeassistant.components.devolo_home_network.const import ( DOMAIN, LONG_UPDATE_INTERVAL, @@ -34,14 +34,16 @@ async def test_device_tracker( snapshot: SnapshotAssertion, ) -> None: """Test device tracker states.""" - state_key = f"{PLATFORM}.{STATION.mac_address.lower().replace(':', '_')}" + entity_id = ( + f"{DEVICE_TRACKER_DOMAIN}.{STATION.mac_address.lower().replace(':', '_')}" + ) entry = configure_integration(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() freezer.tick(LONG_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(state_key) == snapshot + assert hass.states.get(entity_id) == snapshot # Emulate state change mock_device.device.async_get_wifi_connected_station = AsyncMock( @@ -51,7 +53,7 @@ async def test_device_tracker( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_NOT_HOME @@ -63,7 +65,7 @@ async def test_device_tracker( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_UNAVAILABLE @@ -74,10 +76,12 @@ async def test_restoring_clients( entity_registry: er.EntityRegistry, ) -> None: """Test restoring existing device_tracker entities.""" - state_key = f"{PLATFORM}.{STATION.mac_address.lower().replace(':', '_')}" + entity_id = ( + f"{DEVICE_TRACKER_DOMAIN}.{STATION.mac_address.lower().replace(':', '_')}" + ) entry = configure_integration(hass) entity_registry.async_get_or_create( - PLATFORM, + DEVICE_TRACKER_DOMAIN, DOMAIN, f"{STATION.mac_address}", config_entry=entry, @@ -90,6 +94,6 @@ async def test_restoring_clients( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_NOT_HOME diff --git a/tests/components/devolo_home_network/test_image.py b/tests/components/devolo_home_network/test_image.py index 54a8af3af6eb42..9d109857ac1572 100644 --- a/tests/components/devolo_home_network/test_image.py +++ b/tests/components/devolo_home_network/test_image.py @@ -9,7 +9,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.devolo_home_network.const import SHORT_UPDATE_INTERVAL -from homeassistant.components.image import DOMAIN as PLATFORM +from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -37,7 +37,7 @@ async def test_image_setup( assert entry.state is ConfigEntryState.LOADED assert not entity_registry.async_get( - f"{PLATFORM}.{device_name}_guest_wi_fi_credentials_as_qr_code" + f"{IMAGE_DOMAIN}.{device_name}_guest_wi_fi_credentials_as_qr_code" ).disabled @@ -53,18 +53,18 @@ async def test_guest_wifi_qr( """Test showing a QR code of the guest wifi credentials.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_guest_wi_fi_credentials_as_qr_code" + entity_id = f"{IMAGE_DOMAIN}.{device_name}_guest_wi_fi_credentials_as_qr_code" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state.name == "Mock Title Guest Wi-Fi credentials as QR code" assert state.state == dt_util.utcnow().isoformat() - assert entity_registry.async_get(state_key) == snapshot + assert entity_registry.async_get(entity_id) == snapshot client = await hass_client() - resp = await client.get(f"/api/image_proxy/{state_key}") + resp = await client.get(f"/api/image_proxy/{entity_id}") assert resp.status == HTTPStatus.OK body = await resp.read() assert body == snapshot @@ -75,7 +75,7 @@ async def test_guest_wifi_qr( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_UNAVAILABLE @@ -87,11 +87,11 @@ async def test_guest_wifi_qr( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == dt_util.utcnow().isoformat() client = await hass_client() - resp = await client.get(f"/api/image_proxy/{state_key}") + resp = await client.get(f"/api/image_proxy/{entity_id}") assert resp.status == HTTPStatus.OK assert await resp.read() != body diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index 9c609334718dc2..973ee1cdd7dcd0 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -6,14 +6,14 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR -from homeassistant.components.button import DOMAIN as BUTTON -from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN from homeassistant.components.devolo_home_network.const import DOMAIN -from homeassistant.components.image import DOMAIN as IMAGE -from homeassistant.components.sensor import DOMAIN as SENSOR -from homeassistant.components.switch import DOMAIN as SWITCH -from homeassistant.components.update import DOMAIN as UPDATE +from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant @@ -90,13 +90,37 @@ async def test_device( [ ( "mock_device", - (BINARY_SENSOR, BUTTON, DEVICE_TRACKER, IMAGE, SENSOR, SWITCH, UPDATE), + ( + BINARY_SENSOR_DOMAIN, + BUTTON_DOMAIN, + DEVICE_TRACKER_DOMAIN, + IMAGE_DOMAIN, + SENSOR_DOMAIN, + SWITCH_DOMAIN, + UPDATE_DOMAIN, + ), ), ( "mock_repeater_device", - (BUTTON, DEVICE_TRACKER, IMAGE, SENSOR, SWITCH, UPDATE), + ( + BUTTON_DOMAIN, + DEVICE_TRACKER_DOMAIN, + IMAGE_DOMAIN, + SENSOR_DOMAIN, + SWITCH_DOMAIN, + UPDATE_DOMAIN, + ), + ), + ( + "mock_nonwifi_device", + ( + BINARY_SENSOR_DOMAIN, + BUTTON_DOMAIN, + SENSOR_DOMAIN, + SWITCH_DOMAIN, + UPDATE_DOMAIN, + ), ), - ("mock_nonwifi_device", (BINARY_SENSOR, BUTTON, SENSOR, SWITCH, UPDATE)), ], ) async def test_platforms( diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index d01eb9f9e380da..d23c172f7c7ffb 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -13,7 +13,7 @@ LONG_UPDATE_INTERVAL, SHORT_UPDATE_INTERVAL, ) -from homeassistant.components.sensor import DOMAIN as PLATFORM +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -39,28 +39,28 @@ async def test_sensor_setup( assert entry.state is ConfigEntryState.LOADED assert not entity_registry.async_get( - f"{PLATFORM}.{device_name}_connected_wi_fi_clients" + f"{SENSOR_DOMAIN}.{device_name}_connected_wi_fi_clients" ).disabled assert entity_registry.async_get( - f"{PLATFORM}.{device_name}_connected_plc_devices" + f"{SENSOR_DOMAIN}.{device_name}_connected_plc_devices" ).disabled assert entity_registry.async_get( - f"{PLATFORM}.{device_name}_neighboring_wi_fi_networks" + f"{SENSOR_DOMAIN}.{device_name}_neighboring_wi_fi_networks" ).disabled assert not entity_registry.async_get( - f"{PLATFORM}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" + f"{SENSOR_DOMAIN}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" ).disabled assert not entity_registry.async_get( - f"{PLATFORM}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" + f"{SENSOR_DOMAIN}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" ).disabled assert entity_registry.async_get( - f"{PLATFORM}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[2].user_device_name}" + f"{SENSOR_DOMAIN}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[2].user_device_name}" ).disabled assert entity_registry.async_get( - f"{PLATFORM}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[2].user_device_name}" + f"{SENSOR_DOMAIN}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[2].user_device_name}" ).disabled assert entity_registry.async_get( - f"{PLATFORM}.{device_name}_last_restart_of_the_device" + f"{SENSOR_DOMAIN}.{device_name}_last_restart_of_the_device" ).disabled @@ -109,12 +109,12 @@ async def test_sensor( """Test state change of a sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_{name}" + entity_id = f"{SENSOR_DOMAIN}.{device_name}_{name}" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(state_key) == snapshot - assert entity_registry.async_get(state_key) == snapshot + assert hass.states.get(entity_id) == snapshot + assert entity_registry.async_get(entity_id) == snapshot # Emulate device failure setattr(mock_device.device, get_method, AsyncMock(side_effect=DeviceUnavailable)) @@ -123,7 +123,7 @@ async def test_sensor( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_UNAVAILABLE @@ -133,7 +133,7 @@ async def test_sensor( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == expected_state @@ -148,15 +148,15 @@ async def test_update_plc_phyrates( """Test state change of plc_downlink_phyrate and plc_uplink_phyrate sensor devices.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key_downlink = f"{PLATFORM}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" - state_key_uplink = f"{PLATFORM}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" + entity_id_downlink = f"{SENSOR_DOMAIN}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" + entity_id_uplink = f"{SENSOR_DOMAIN}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(state_key_downlink) == snapshot - assert entity_registry.async_get(state_key_downlink) == snapshot - assert hass.states.get(state_key_downlink) == snapshot - assert entity_registry.async_get(state_key_downlink) == snapshot + assert hass.states.get(entity_id_downlink) == snapshot + assert entity_registry.async_get(entity_id_downlink) == snapshot + assert hass.states.get(entity_id_downlink) == snapshot + assert entity_registry.async_get(entity_id_downlink) == snapshot # Emulate device failure mock_device.plcnet.async_get_network_overview = AsyncMock( @@ -166,11 +166,11 @@ async def test_update_plc_phyrates( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key_downlink) + state = hass.states.get(entity_id_downlink) assert state is not None assert state.state == STATE_UNAVAILABLE - state = hass.states.get(state_key_uplink) + state = hass.states.get(entity_id_uplink) assert state is not None assert state.state == STATE_UNAVAILABLE @@ -180,11 +180,11 @@ async def test_update_plc_phyrates( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key_downlink) + state = hass.states.get(entity_id_downlink) assert state is not None assert state.state == str(PLCNET.data_rates[0].rx_rate) - state = hass.states.get(state_key_uplink) + state = hass.states.get(entity_id_uplink) assert state is not None assert state.state == str(PLCNET.data_rates[0].tx_rate) diff --git a/tests/components/devolo_home_network/test_switch.py b/tests/components/devolo_home_network/test_switch.py index 1ab2a1c354b625..2d4cb2f191ca20 100644 --- a/tests/components/devolo_home_network/test_switch.py +++ b/tests/components/devolo_home_network/test_switch.py @@ -13,7 +13,7 @@ DOMAIN, SHORT_UPDATE_INTERVAL, ) -from homeassistant.components.switch import DOMAIN as PLATFORM +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, @@ -47,10 +47,10 @@ async def test_switch_setup( assert entry.state is ConfigEntryState.LOADED assert not entity_registry.async_get( - f"{PLATFORM}.{device_name}_enable_guest_wi_fi" + f"{SWITCH_DOMAIN}.{device_name}_enable_guest_wi_fi" ).disabled assert not entity_registry.async_get( - f"{PLATFORM}.{device_name}_enable_leds" + f"{SWITCH_DOMAIN}.{device_name}_enable_leds" ).disabled @@ -87,13 +87,13 @@ async def test_update_enable_guest_wifi( """Test state change of a enable_guest_wifi switch device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_enable_guest_wi_fi" + entity_id = f"{SWITCH_DOMAIN}.{device_name}_enable_guest_wi_fi" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(state_key) == snapshot - assert entity_registry.async_get(state_key) == snapshot + assert hass.states.get(entity_id) == snapshot + assert entity_registry.async_get(entity_id) == snapshot # Emulate state change mock_device.device.async_get_wifi_guest_access.return_value = WifiGuestAccessGet( @@ -103,7 +103,7 @@ async def test_update_enable_guest_wifi( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_ON @@ -112,10 +112,10 @@ async def test_update_enable_guest_wifi( enabled=False ) await hass.services.async_call( - PLATFORM, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: state_key}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_OFF mock_device.device.async_set_wifi_guest_access.assert_called_once_with(False) @@ -130,10 +130,10 @@ async def test_update_enable_guest_wifi( enabled=True ) await hass.services.async_call( - PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_ON mock_device.device.async_set_wifi_guest_access.assert_called_once_with(True) @@ -151,9 +151,9 @@ async def test_update_enable_guest_wifi( HomeAssistantError, match=f"Device {entry.title} did not respond" ): await hass.services.async_call( - PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_UNAVAILABLE @@ -168,13 +168,13 @@ async def test_update_enable_leds( """Test state change of a enable_leds switch device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_enable_leds" + entity_id = f"{SWITCH_DOMAIN}.{device_name}_enable_leds" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(state_key) == snapshot - assert entity_registry.async_get(state_key) == snapshot + assert hass.states.get(entity_id) == snapshot + assert entity_registry.async_get(entity_id) == snapshot # Emulate state change mock_device.device.async_get_led_setting.return_value = True @@ -182,17 +182,17 @@ async def test_update_enable_leds( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_ON # Switch off mock_device.device.async_get_led_setting.return_value = False await hass.services.async_call( - PLATFORM, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: state_key}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_OFF mock_device.device.async_set_led_setting.assert_called_once_with(False) @@ -205,10 +205,10 @@ async def test_update_enable_leds( # Switch on mock_device.device.async_get_led_setting.return_value = True await hass.services.async_call( - PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_ON mock_device.device.async_set_led_setting.assert_called_once_with(True) @@ -226,9 +226,9 @@ async def test_update_enable_leds( HomeAssistantError, match=f"Device {entry.title} did not respond" ): await hass.services.async_call( - PLATFORM, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: state_key}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_UNAVAILABLE @@ -251,12 +251,12 @@ async def test_device_failure( """Test device failure.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_{name}" + entity_id = f"{SWITCH_DOMAIN}.{device_name}_{name}" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None api = getattr(mock_device.device, get_method) @@ -265,7 +265,7 @@ async def test_device_failure( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_UNAVAILABLE @@ -283,12 +283,12 @@ async def test_auth_failed( """Test setting unautherized triggers the reauth flow.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_{name}" + entity_id = f"{SWITCH_DOMAIN}.{device_name}_{name}" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None setattr(mock_device.device, set_method, AsyncMock()) @@ -297,7 +297,7 @@ async def test_auth_failed( with pytest.raises(HomeAssistantError): await hass.services.async_call( - PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) await hass.async_block_till_done() @@ -314,7 +314,7 @@ async def test_auth_failed( with pytest.raises(HomeAssistantError): await hass.services.async_call( - PLATFORM, SERVICE_TURN_OFF, {"entity_id": state_key}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {"entity_id": entity_id}, blocking=True ) flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 diff --git a/tests/components/devolo_home_network/test_update.py b/tests/components/devolo_home_network/test_update.py index 034d1bad7f6409..59cebe5cc3dd28 100644 --- a/tests/components/devolo_home_network/test_update.py +++ b/tests/components/devolo_home_network/test_update.py @@ -10,7 +10,7 @@ DOMAIN, FIRMWARE_UPDATE_INTERVAL, ) -from homeassistant.components.update import DOMAIN as PLATFORM, SERVICE_INSTALL +from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -36,7 +36,9 @@ async def test_update_setup( await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - assert not entity_registry.async_get(f"{PLATFORM}.{device_name}_firmware").disabled + assert not entity_registry.async_get( + f"{UPDATE_DOMAIN}.{device_name}_firmware" + ).disabled async def test_update_firmware( @@ -50,18 +52,18 @@ async def test_update_firmware( """Test updating a device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_firmware" + entity_id = f"{UPDATE_DOMAIN}.{device_name}_firmware" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(state_key) == snapshot - assert entity_registry.async_get(state_key) == snapshot + assert hass.states.get(entity_id) == snapshot + assert entity_registry.async_get(entity_id) == snapshot await hass.services.async_call( - PLATFORM, + UPDATE_DOMAIN, SERVICE_INSTALL, - {ATTR_ENTITY_ID: state_key}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) assert mock_device.device.async_start_firmware_update.call_count == 1 @@ -77,7 +79,7 @@ async def test_update_firmware( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_OFF @@ -96,12 +98,12 @@ async def test_device_failure_check( """Test device failure during check.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_firmware" + entity_id = f"{UPDATE_DOMAIN}.{device_name}_firmware" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None mock_device.device.async_check_firmware_available.side_effect = DeviceUnavailable @@ -109,7 +111,7 @@ async def test_device_failure_check( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_UNAVAILABLE @@ -121,7 +123,7 @@ async def test_device_failure_update( """Test device failure when starting update.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_firmware" + entity_id = f"{UPDATE_DOMAIN}.{device_name}_firmware" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -131,9 +133,9 @@ async def test_device_failure_update( # Emulate update start with pytest.raises(HomeAssistantError): await hass.services.async_call( - PLATFORM, + UPDATE_DOMAIN, SERVICE_INSTALL, - {ATTR_ENTITY_ID: state_key}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) @@ -142,7 +144,7 @@ async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None """Test updating unauthorized triggers the reauth flow.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_firmware" + entity_id = f"{UPDATE_DOMAIN}.{device_name}_firmware" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -151,9 +153,9 @@ async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None with pytest.raises(HomeAssistantError): assert await hass.services.async_call( - PLATFORM, + UPDATE_DOMAIN, SERVICE_INSTALL, - {ATTR_ENTITY_ID: state_key}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) await hass.async_block_till_done() diff --git a/tests/components/ecovacs/test_lawn_mower.py b/tests/components/ecovacs/test_lawn_mower.py index bab1495e16c1fc..2c5b8c530c758e 100644 --- a/tests/components/ecovacs/test_lawn_mower.py +++ b/tests/components/ecovacs/test_lawn_mower.py @@ -12,7 +12,7 @@ from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController from homeassistant.components.lawn_mower import ( - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as LAWN_MOWER_DOMAIN, SERVICE_DOCK, SERVICE_PAUSE, SERVICE_START_MOWING, @@ -108,7 +108,7 @@ async def test_mover_services( for test in tests: device._execute_command.reset_mock() await hass.services.async_call( - PLATFORM_DOMAIN, + LAWN_MOWER_DOMAIN, test.service_name, {ATTR_ENTITY_ID: entity_id}, blocking=True, diff --git a/tests/components/ecovacs/test_number.py b/tests/components/ecovacs/test_number.py index 02628554519eb2..f7a1705c955417 100644 --- a/tests/components/ecovacs/test_number.py +++ b/tests/components/ecovacs/test_number.py @@ -18,7 +18,7 @@ from homeassistant.components.ecovacs.controller import EcovacsController from homeassistant.components.number import ( ATTR_VALUE, - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform @@ -139,7 +139,7 @@ async def test_number_entities( device._execute_command.reset_mock() await hass.services.async_call( - PLATFORM_DOMAIN, + NUMBER_DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: test_case.set_value}, blocking=True, diff --git a/tests/components/ecovacs/test_switch.py b/tests/components/ecovacs/test_switch.py index ef79865d354cb7..f62b5a5afdb3cc 100644 --- a/tests/components/ecovacs/test_switch.py +++ b/tests/components/ecovacs/test_switch.py @@ -33,7 +33,7 @@ from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController -from homeassistant.components.switch import DOMAIN as PLATFORM_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -196,7 +196,7 @@ async def test_switch_entities( device._execute_command.reset_mock() await hass.services.async_call( - PLATFORM_DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True, @@ -205,7 +205,7 @@ async def test_switch_entities( device._execute_command.reset_mock() await hass.services.async_call( - PLATFORM_DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True, diff --git a/tests/components/facebook/test_notify.py b/tests/components/facebook/test_notify.py index db9cd86e086ba8..4e889b26dc006a 100644 --- a/tests/components/facebook/test_notify.py +++ b/tests/components/facebook/test_notify.py @@ -34,7 +34,7 @@ async def test_send_simple_message( "recipient": {"phone_number": target[0]}, "message": {"text": message}, "messaging_type": "MESSAGE_TAG", - "tag": "ACCOUNT_UPDATE", + "tag": "HUMAN_AGENT", } assert mock.last_request.json() == expected_body @@ -62,7 +62,7 @@ async def test_send_multiple_message( "recipient": {"phone_number": target}, "message": {"text": message}, "messaging_type": "MESSAGE_TAG", - "tag": "ACCOUNT_UPDATE", + "tag": "HUMAN_AGENT", } assert request.json() == expected_body @@ -94,7 +94,7 @@ async def test_send_message_attachment( "recipient": {"phone_number": target[0]}, "message": data, "messaging_type": "MESSAGE_TAG", - "tag": "ACCOUNT_UPDATE", + "tag": "HUMAN_AGENT", } assert mock.last_request.json() == expected_body diff --git a/tests/components/fritz/test_sensor.py b/tests/components/fritz/test_sensor.py index d8820eb9b0eb79..4731b9845163f3 100644 --- a/tests/components/fritz/test_sensor.py +++ b/tests/components/fritz/test_sensor.py @@ -10,13 +10,13 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.fritz.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.fritz.const import DOMAIN, SCAN_INTERVAL, UPTIME_DEVIATION from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .const import MOCK_USER_DATA +from .const import MOCK_FB_SERVICES, MOCK_USER_DATA from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -69,3 +69,45 @@ async def test_sensor_update_fail( sensors = hass.states.async_all(SENSOR_DOMAIN) for sensor in sensors: assert sensor.state == STATE_UNAVAILABLE + + +@pytest.mark.freeze_time("2026-02-14T09:30:00+00:00") +async def test_sensor_uptime_spike( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, + fc_class_mock, + fh_class_mock, + fs_class_mock, +) -> None: + """Test handling of uptime spikes in Fritz!Tools sensors.""" + + entity_id = "sensor.mock_title_last_restart" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == "2026-01-16T06:00:21+00:00" + + # Simulate uptime spike by setting uptime to a value between + # the previous one and a delta smaller than UPTIME_DEVIATION + base_uptime = MOCK_FB_SERVICES["DeviceInfo1"]["GetInfo"]["NewUpTime"] + update_uptime = { + "DeviceInfo1": { + "GetInfo": { + "NewUpTime": base_uptime + SCAN_INTERVAL - UPTIME_DEVIATION + 1, + }, + }, + } + fc_class_mock().override_services({**MOCK_FB_SERVICES, **update_uptime}) + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (new_state := hass.states.get(entity_id)) + assert new_state.state == "2026-01-16T06:00:21+00:00" diff --git a/tests/components/gios/snapshots/test_sensor.ambr b/tests/components/gios/snapshots/test_sensor.ambr index e5125b140d703b..8ef0f86216a14c 100644 --- a/tests/components/gios/snapshots/test_sensor.ambr +++ b/tests/components/gios/snapshots/test_sensor.ambr @@ -153,14 +153,14 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Carbon monoxide', 'platform': 'gios', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'co', + 'translation_key': None, 'unique_id': '123-co', 'unit_of_measurement': 'μg/m³', }) @@ -169,6 +169,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by GIOŚ', + 'device_class': 'carbon_monoxide', 'friendly_name': 'Home Carbon monoxide', 'state_class': , 'unit_of_measurement': 'μg/m³', diff --git a/tests/components/gios/test_config_flow.py b/tests/components/gios/test_config_flow.py index b7229c621be12b..b0b676fdfc32ec 100644 --- a/tests/components/gios/test_config_flow.py +++ b/tests/components/gios/test_config_flow.py @@ -11,6 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + CONFIG = { CONF_STATION_ID: "123", } @@ -18,8 +20,8 @@ pytestmark = pytest.mark.usefixtures("mock_gios") -async def test_show_form(hass: HomeAssistant) -> None: - """Test that the form is served with no input.""" +async def test_happy_flow(hass: HomeAssistant) -> None: + """Test that the user step works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -28,6 +30,19 @@ async def test_show_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert len(result["data_schema"].schema[CONF_STATION_ID].config["options"]) == 2 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Home" + assert result["data"] == { + CONF_STATION_ID: 123, + CONF_NAME: "Home", + } + + assert result["result"].unique_id == "123" + async def test_form_with_api_error(hass: HomeAssistant, mock_gios: MagicMock) -> None: """Test the form is aborted because of API error.""" @@ -76,21 +91,19 @@ async def test_form_submission_errors( assert result["title"] == "Home" -async def test_create_entry(hass: HomeAssistant) -> None: - """Test that the user step works.""" +async def test_duplicate_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that duplicate station IDs are rejected.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, + DOMAIN, context={"source": SOURCE_USER} ) + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=CONFIG ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Home" - assert result["data"] == { - CONF_STATION_ID: 123, - CONF_NAME: "Home", - } - - assert result["result"].unique_id == "123" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/gios/test_init.py b/tests/components/gios/test_init.py index 20944ea44276f9..97e1f2f6462c3f 100644 --- a/tests/components/gios/test_init.py +++ b/tests/components/gios/test_init.py @@ -4,12 +4,10 @@ import pytest -from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.components.gios.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr from . import setup_integration @@ -19,12 +17,10 @@ @pytest.mark.usefixtures("init_integration") async def test_async_setup_entry( hass: HomeAssistant, + mock_config_entry: MockConfigEntry, ) -> None: """Test a successful setup entry.""" - state = hass.states.get("sensor.home_pm2_5") - assert state is not None - assert state.state != STATE_UNAVAILABLE - assert state.state == "4" + assert mock_config_entry.state is ConfigEntryState.LOADED async def test_config_not_ready( @@ -93,26 +89,3 @@ async def test_migrate_unique_id_to_str( await setup_integration(hass, mock_config_entry) assert mock_config_entry.unique_id == "123" - - -async def test_remove_air_quality_entities( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - mock_config_entry: MockConfigEntry, - mock_gios: MagicMock, -) -> None: - """Test remove air_quality entities from registry.""" - mock_config_entry.add_to_hass(hass) - entity_registry.async_get_or_create( - AIR_QUALITY_PLATFORM, - DOMAIN, - "123", - suggested_object_id="home", - disabled_by=None, - ) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - entry = entity_registry.async_get("air_quality.home") - assert entry is None diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py index b668de99a4e7f9..37cd27b78b61c0 100644 --- a/tests/components/gios/test_sensor.py +++ b/tests/components/gios/test_sensor.py @@ -37,22 +37,6 @@ async def test_sensor( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.usefixtures("init_integration") -async def test_availability(hass: HomeAssistant) -> None: - """Ensure that we mark the entities unavailable correctly when service causes an error.""" - state = hass.states.get("sensor.home_pm2_5") - assert state - assert state.state == "4" - - state = hass.states.get("sensor.home_pm2_5_index") - assert state - assert state.state == "good" - - state = hass.states.get("sensor.home_air_quality_index") - assert state - assert state.state == "good" - - @pytest.mark.usefixtures("init_integration") async def test_availability_api_error( hass: HomeAssistant, diff --git a/tests/components/growatt_server/conftest.py b/tests/components/growatt_server/conftest.py index 08399f4034d985..10e5884825bf42 100644 --- a/tests/components/growatt_server/conftest.py +++ b/tests/components/growatt_server/conftest.py @@ -64,7 +64,8 @@ def mock_growatt_v1_api(): "chargePowerCommand": 50, # 50% charge power - read by number entity "wchargeSOCLowLimit": 10, # 10% charge stop SOC - read by number entity "disChargePowerCommand": 80, # 80% discharge power - read by number entity - "wdisChargeSOCLowLimit": 20, # 20% discharge stop SOC - read by number entity + "wdisChargeSOCLowLimit": 20, # 20% discharge stop SOC (off-grid) - read by number entity + "onGridDischargeStopSOC": 15, # 15% on-grid discharge stop SOC - read by number entity } # Called by MIN device coordinator during refresh diff --git a/tests/components/growatt_server/snapshots/test_number.ambr b/tests/components/growatt_server/snapshots/test_number.ambr index e43cf4fea40ace..278ce3b0ad8c5f 100644 --- a/tests/components/growatt_server/snapshots/test_number.ambr +++ b/tests/components/growatt_server/snapshots/test_number.ambr @@ -176,7 +176,7 @@ 'state': '80', }) # --- -# name: test_number_entities[number.min123456_battery_discharge_soc_limit-entry] +# name: test_number_entities[number.min123456_battery_discharge_soc_limit_off_grid-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -194,7 +194,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': , - 'entity_id': 'number.min123456_battery_discharge_soc_limit', + 'entity_id': 'number.min123456_battery_discharge_soc_limit_off_grid', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -202,25 +202,25 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Battery discharge SOC limit', + 'object_id_base': 'Battery discharge SOC limit (off-grid)', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Battery discharge SOC limit', + 'original_name': 'Battery discharge SOC limit (off-grid)', 'platform': 'growatt_server', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'battery_discharge_soc_limit', + 'translation_key': 'battery_discharge_soc_limit_off_grid', 'unique_id': 'MIN123456_battery_discharge_soc_limit', 'unit_of_measurement': '%', }) # --- -# name: test_number_entities[number.min123456_battery_discharge_soc_limit-state] +# name: test_number_entities[number.min123456_battery_discharge_soc_limit_off_grid-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'MIN123456 Battery discharge SOC limit', + 'friendly_name': 'MIN123456 Battery discharge SOC limit (off-grid)', 'max': 100, 'min': 0, 'mode': , @@ -228,10 +228,69 @@ 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'number.min123456_battery_discharge_soc_limit', + 'entity_id': 'number.min123456_battery_discharge_soc_limit_off_grid', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '20', }) # --- +# name: test_number_entities[number.min123456_battery_discharge_soc_limit_on_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.min123456_battery_discharge_soc_limit_on_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery discharge SOC limit (on-grid)', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery discharge SOC limit (on-grid)', + 'platform': 'growatt_server', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_discharge_soc_limit_on_grid', + 'unique_id': 'MIN123456_battery_discharge_soc_limit_on_grid', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number_entities[number.min123456_battery_discharge_soc_limit_on_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MIN123456 Battery discharge SOC limit (on-grid)', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.min123456_battery_discharge_soc_limit_on_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- diff --git a/tests/components/growatt_server/test_number.py b/tests/components/growatt_server/test_number.py index 0d06b0d8d5010f..f78af273a3784a 100644 --- a/tests/components/growatt_server/test_number.py +++ b/tests/components/growatt_server/test_number.py @@ -72,12 +72,21 @@ async def test_all_number_entities_service_calls( mock_growatt_v1_api, ) -> None: """Test service calls work for all number entities.""" - # Test all four number entities + # Test all five number entities test_cases = [ ("number.min123456_battery_charge_power_limit", "charge_power", 75), ("number.min123456_battery_charge_soc_limit", "charge_stop_soc", 85), ("number.min123456_battery_discharge_power_limit", "discharge_power", 90), - ("number.min123456_battery_discharge_soc_limit", "discharge_stop_soc", 25), + ( + "number.min123456_battery_discharge_soc_limit_off_grid", + "discharge_stop_soc", + 25, + ), + ( + "number.min123456_battery_discharge_soc_limit_on_grid", + "on_grid_discharge_stop_soc", + 30, + ), ] for entity_id, expected_write_key, test_value in test_cases: @@ -110,6 +119,7 @@ async def test_number_missing_data( "wchargeSOCLowLimit": 10, "disChargePowerCommand": 80, "wdisChargeSOCLowLimit": 20, + "onGridDischargeStopSOC": 15, } mock_config_entry.add_to_hass(hass) @@ -228,6 +238,7 @@ async def test_number_coordinator_data_update( "wchargeSOCLowLimit": 10, "disChargePowerCommand": 80, "wdisChargeSOCLowLimit": 20, + "onGridDischargeStopSOC": 15, } # Advance time to trigger coordinator refresh diff --git a/tests/components/homeassistant_connect_zbt2/test_config_flow.py b/tests/components/homeassistant_connect_zbt2/test_config_flow.py index d50d02172f1ce3..43ac3e8a41eb6c 100644 --- a/tests/components/homeassistant_connect_zbt2/test_config_flow.py +++ b/tests/components/homeassistant_connect_zbt2/test_config_flow.py @@ -6,6 +6,9 @@ import pytest from homeassistant.components.homeassistant_connect_zbt2.const import DOMAIN +from homeassistant.components.homeassistant_hardware import ( + DOMAIN as HOMEASSISTANT_HARDWARE_DOMAIN, +) from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, @@ -18,7 +21,7 @@ FirmwareInfo, ResetTarget, ) -from homeassistant.components.usb import USBDevice +from homeassistant.components.usb import DOMAIN as USB_DOMAIN, USBDevice from homeassistant.config_entries import ConfigFlowResult from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -442,8 +445,8 @@ async def test_duplicate_discovery_updates_usb_path(hass: HomeAssistant) -> None async def test_firmware_callback_auto_creates_entry(hass: HomeAssistant) -> None: """Test that firmware notification triggers import flow that auto-creates config entry.""" - await async_setup_component(hass, "homeassistant_hardware", {}) - await async_setup_component(hass, "usb", {}) + await async_setup_component(hass, HOMEASSISTANT_HARDWARE_DOMAIN, {}) + await async_setup_component(hass, USB_DOMAIN, {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "usb"}, data=USB_DATA_ZBT2 @@ -499,8 +502,8 @@ async def test_firmware_callback_auto_creates_entry(hass: HomeAssistant) -> None async def test_firmware_callback_updates_existing_entry(hass: HomeAssistant) -> None: """Test that firmware notification updates existing config entry device path.""" - await async_setup_component(hass, "homeassistant_hardware", {}) - await async_setup_component(hass, "usb", {}) + await async_setup_component(hass, HOMEASSISTANT_HARDWARE_DOMAIN, {}) + await async_setup_component(hass, USB_DOMAIN, {}) # Create existing config entry with old device path config_entry = MockConfigEntry( diff --git a/tests/components/homeassistant_connect_zbt2/test_hardware.py b/tests/components/homeassistant_connect_zbt2/test_hardware.py index 030a2610d647ff..a0bd22e1c932d0 100644 --- a/tests/components/homeassistant_connect_zbt2/test_hardware.py +++ b/tests/components/homeassistant_connect_zbt2/test_hardware.py @@ -1,6 +1,7 @@ """Test the Home Assistant Connect ZBT-2 hardware platform.""" from homeassistant.components.homeassistant_connect_zbt2.const import DOMAIN +from homeassistant.components.usb import DOMAIN as USB_DOMAIN from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -24,7 +25,7 @@ async def test_hardware_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, addon_store_info ) -> None: """Test we can get the board info.""" - assert await async_setup_component(hass, "usb", {}) + assert await async_setup_component(hass, USB_DOMAIN, {}) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) # Setup the config entry diff --git a/tests/components/homeassistant_connect_zbt2/test_init.py b/tests/components/homeassistant_connect_zbt2/test_init.py index 42f5f8ac5a5e45..09a89ab13fab74 100644 --- a/tests/components/homeassistant_connect_zbt2/test_init.py +++ b/tests/components/homeassistant_connect_zbt2/test_init.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.homeassistant_connect_zbt2.const import DOMAIN -from homeassistant.components.usb import USBDevice +from homeassistant.components.usb import DOMAIN as USB_DOMAIN, USBDevice from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant @@ -65,7 +65,7 @@ async def test_setup_fails_on_missing_usb_port(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("force_usb_polling_watcher") async def test_usb_device_reactivity(hass: HomeAssistant) -> None: """Test setting up USB monitoring.""" - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, USB_DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index e3dd3d70eed27c..be6459dc0a8022 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -16,7 +16,10 @@ import pytest from yarl import URL -from homeassistant.components.homeassistant_hardware.const import Z2M_EMBER_DOCS_URL +from homeassistant.components.homeassistant_hardware.const import ( + DOMAIN, + Z2M_EMBER_DOCS_URL, +) from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, @@ -195,7 +198,7 @@ async def mock_test_firmware_platform( mock_integration(hass, mock_module) mock_platform(hass, f"{TEST_DOMAIN}.config_flow") - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) with mock_config_flow(TEST_DOMAIN, FakeFirmwareConfigFlow): yield diff --git a/tests/components/homeassistant_hardware/test_helpers.py b/tests/components/homeassistant_hardware/test_helpers.py index 540d2ca7afdddc..5ebe955f4e9301 100644 --- a/tests/components/homeassistant_hardware/test_helpers.py +++ b/tests/components/homeassistant_hardware/test_helpers.py @@ -6,7 +6,7 @@ import pytest -from homeassistant.components.homeassistant_hardware.const import DATA_COMPONENT +from homeassistant.components.homeassistant_hardware.const import DATA_COMPONENT, DOMAIN from homeassistant.components.homeassistant_hardware.helpers import ( async_firmware_update_context, async_is_firmware_update_in_progress, @@ -20,7 +20,7 @@ ApplicationType, FirmwareInfo, ) -from homeassistant.components.usb import USBDevice +from homeassistant.components.usb import DOMAIN as USB_DOMAIN, USBDevice from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -47,7 +47,7 @@ async def test_dispatcher_registration(hass: HomeAssistant) -> None: """Test HardwareInfoDispatcher registration.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) # Mock provider 1 with a synchronous method to pull firmware info provider1_config_entry = MockConfigEntry( @@ -123,7 +123,7 @@ async def test_dispatcher_iter_error_handling( ) -> None: """Test HardwareInfoDispatcher ignoring errors from firmware info providers.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) provider1_config_entry = MockConfigEntry( domain="zha", @@ -163,7 +163,7 @@ async def test_dispatcher_callback_error_handling( ) -> None: """Test HardwareInfoDispatcher ignoring errors from firmware info callbacks.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) provider1_config_entry = MockConfigEntry( domain="zha", unique_id="some_unique_id1", @@ -193,7 +193,7 @@ async def test_dispatcher_callback_error_handling( async def test_firmware_update_tracking(hass: HomeAssistant) -> None: """Test firmware update tracking API.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) device_path = "/dev/ttyUSB0" @@ -225,7 +225,7 @@ async def test_firmware_update_tracking(hass: HomeAssistant) -> None: async def test_firmware_update_context_manager(hass: HomeAssistant) -> None: """Test firmware update progress context manager.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) device_path = "/dev/ttyUSB0" @@ -263,7 +263,7 @@ async def test_firmware_update_context_manager(hass: HomeAssistant) -> None: async def test_dispatcher_callback_self_unregister(hass: HomeAssistant) -> None: """Test callbacks can unregister themselves during notification.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) called_callbacks = [] unregister_funcs = {} @@ -304,8 +304,8 @@ async def test_firmware_callback_no_usb_device( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test firmware notification when usb_device_from_path returns None.""" - await async_setup_component(hass, "homeassistant_hardware", {}) - await async_setup_component(hass, "usb", {}) + await async_setup_component(hass, DOMAIN, {}) + await async_setup_component(hass, USB_DOMAIN, {}) with ( patch( @@ -335,8 +335,8 @@ async def test_firmware_callback_no_hardware_domain( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test firmware notification when no hardware domain is found for device.""" - await async_setup_component(hass, "homeassistant_hardware", {}) - await async_setup_component(hass, "usb", {}) + await async_setup_component(hass, DOMAIN, {}) + await async_setup_component(hass, USB_DOMAIN, {}) # Create a USB device that doesn't match any hardware integration usb_device = USBDevice( diff --git a/tests/components/homeassistant_hardware/test_switch.py b/tests/components/homeassistant_hardware/test_switch.py index a856aa33f0f2a0..c145fb411cd535 100644 --- a/tests/components/homeassistant_hardware/test_switch.py +++ b/tests/components/homeassistant_hardware/test_switch.py @@ -7,6 +7,8 @@ import pytest +from homeassistant.components.homeassistant import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.components.homeassistant_hardware import DOMAIN from homeassistant.components.homeassistant_hardware.coordinator import ( FirmwareUpdateCoordinator, ) @@ -122,8 +124,8 @@ async def mock_switch_config_entry( mock_firmware_client, ) -> AsyncGenerator[ConfigEntry]: """Set up a mock config entry for testing.""" - await async_setup_component(hass, "homeassistant", {}) - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) + await async_setup_component(hass, DOMAIN, {}) mock_integration( hass, diff --git a/tests/components/homeassistant_hardware/test_update.py b/tests/components/homeassistant_hardware/test_update.py index cd2298dfb437fa..f04cd33cb89b71 100644 --- a/tests/components/homeassistant_hardware/test_update.py +++ b/tests/components/homeassistant_hardware/test_update.py @@ -15,6 +15,7 @@ DOMAIN as HOMEASSISTANT_DOMAIN, SERVICE_UPDATE_ENTITY, ) +from homeassistant.components.homeassistant_hardware import DOMAIN from homeassistant.components.homeassistant_hardware.coordinator import ( FirmwareUpdateCoordinator, ) @@ -232,7 +233,7 @@ async def mock_update_config_entry( ) -> AsyncGenerator[ConfigEntry]: """Set up a mock Home Assistant Hardware firmware update entity.""" await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) mock_integration( hass, diff --git a/tests/components/homeassistant_hardware/test_util.py b/tests/components/homeassistant_hardware/test_util.py index bc650b4cc7a52a..95644478ed8f1c 100644 --- a/tests/components/homeassistant_hardware/test_util.py +++ b/tests/components/homeassistant_hardware/test_util.py @@ -15,6 +15,7 @@ AddonManager, AddonState, ) +from homeassistant.components.homeassistant_hardware import DOMAIN from homeassistant.components.homeassistant_hardware.helpers import ( async_register_firmware_info_provider, ) @@ -72,7 +73,7 @@ async def test_guess_firmware_info_unknown(hass: HomeAssistant) -> None: """Test guessing the firmware type.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) assert (await guess_firmware_info(hass, "/dev/missing")) == FirmwareInfo( device="/dev/missing", @@ -86,7 +87,7 @@ async def test_guess_firmware_info_unknown(hass: HomeAssistant) -> None: async def test_guess_firmware_info_integrations(hass: HomeAssistant) -> None: """Test guessing the firmware via OTBR and ZHA.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) # One instance of ZHA and two OTBRs zha = MockConfigEntry(domain="zha", unique_id="some_unique_id_1") @@ -553,7 +554,7 @@ async def test_probe_silabs_firmware_type( async def test_async_flash_silabs_firmware(hass: HomeAssistant) -> None: """Test async_flash_silabs_firmware.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) owner1 = create_mock_owner() owner2 = create_mock_owner() @@ -687,7 +688,7 @@ async def test_async_flash_silabs_firmware_flash_failure( hass: HomeAssistant, side_effect: Exception, expected_error_msg: str ) -> None: """Test async_flash_silabs_firmware flash failure.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) owner1 = create_mock_owner() owner2 = create_mock_owner() @@ -748,7 +749,7 @@ async def test_async_flash_silabs_firmware_flash_failure( async def test_async_flash_silabs_firmware_probe_failure(hass: HomeAssistant) -> None: """Test async_flash_silabs_firmware probe failure.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) owner1 = create_mock_owner() owner2 = create_mock_owner() diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index b0d58473a6786f..3ec09b358e7987 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -6,6 +6,9 @@ import pytest from homeassistant.components.hassio import AddonInfo, AddonState +from homeassistant.components.homeassistant_hardware import ( + DOMAIN as HOMEASSISTANT_HARDWARE_DOMAIN, +) from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, @@ -23,7 +26,7 @@ FirmwareInfo, ) from homeassistant.components.homeassistant_sky_connect.const import DOMAIN -from homeassistant.components.usb import USBDevice +from homeassistant.components.usb import DOMAIN as USB_DOMAIN, USBDevice from homeassistant.config_entries import ConfigFlowResult from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -446,8 +449,8 @@ async def test_firmware_callback_auto_creates_entry( hass: HomeAssistant, ) -> None: """Test that firmware notification triggers import flow that auto-creates config entry.""" - await async_setup_component(hass, "homeassistant_hardware", {}) - await async_setup_component(hass, "usb", {}) + await async_setup_component(hass, HOMEASSISTANT_HARDWARE_DOMAIN, {}) + await async_setup_component(hass, USB_DOMAIN, {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "usb"}, data=usb_data @@ -556,8 +559,8 @@ async def test_firmware_callback_updates_existing_entry( usb_data: UsbServiceInfo, model: str, hass: HomeAssistant ) -> None: """Test that firmware notification updates existing config entry device path.""" - await async_setup_component(hass, "homeassistant_hardware", {}) - await async_setup_component(hass, "usb", {}) + await async_setup_component(hass, HOMEASSISTANT_HARDWARE_DOMAIN, {}) + await async_setup_component(hass, USB_DOMAIN, {}) # Create existing config entry with old device path config_entry = MockConfigEntry( diff --git a/tests/components/homeassistant_sky_connect/test_hardware.py b/tests/components/homeassistant_sky_connect/test_hardware.py index 2a594ebcdad31b..2df7076ab745d6 100644 --- a/tests/components/homeassistant_sky_connect/test_hardware.py +++ b/tests/components/homeassistant_sky_connect/test_hardware.py @@ -1,6 +1,7 @@ """Test the Home Assistant SkyConnect hardware platform.""" from homeassistant.components.homeassistant_sky_connect.const import DOMAIN +from homeassistant.components.usb import DOMAIN as USB_DOMAIN from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -37,7 +38,7 @@ async def test_hardware_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, addon_store_info ) -> None: """Test we can get the board info.""" - assert await async_setup_component(hass, "usb", {}) + assert await async_setup_component(hass, USB_DOMAIN, {}) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) # Setup the config entry diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index f027a6d2fb87f5..37039a968fb38d 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -18,7 +18,7 @@ SERIAL_NUMBER, VID, ) -from homeassistant.components.usb import USBDevice +from homeassistant.components.usb import DOMAIN as USB_DOMAIN, USBDevice from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant @@ -126,7 +126,7 @@ async def test_setup_fails_on_missing_usb_port(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("force_usb_polling_watcher") async def test_usb_device_reactivity(hass: HomeAssistant) -> None: """Test setting up USB monitoring.""" - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, USB_DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) diff --git a/tests/components/homee/fixtures/sensors.json b/tests/components/homee/fixtures/sensors.json index 1c743195a20b18..0c811b03a3c65b 100644 --- a/tests/components/homee/fixtures/sensors.json +++ b/tests/components/homee/fixtures/sensors.json @@ -111,7 +111,7 @@ "current_value": 175.0, "target_value": 175.0, "last_value": 66.0, - "unit": "lx", + "unit": "Lux", "step_value": 1.0, "editable": 0, "type": 11, @@ -126,6 +126,27 @@ { "id": 6, "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 65000, + "current_value": 175.0, + "target_value": 175.0, + "last_value": 66.0, + "unit": "lx", + "step_value": 1.0, + "editable": 0, + "type": 11, + "state": 1, + "last_changed": 1709982926, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 7, + "node_id": 1, "instance": 2, "minimum": 1, "maximum": 100, @@ -145,7 +166,7 @@ "name": "" }, { - "id": 7, + "id": 8, "node_id": 1, "instance": 1, "minimum": 0, @@ -166,7 +187,7 @@ "name": "" }, { - "id": 8, + "id": 9, "node_id": 1, "instance": 2, "minimum": 0, @@ -187,7 +208,7 @@ "name": "" }, { - "id": 9, + "id": 10, "node_id": 1, "instance": 0, "minimum": 0, @@ -208,7 +229,7 @@ "name": "" }, { - "id": 10, + "id": 11, "node_id": 1, "instance": 0, "minimum": 0, @@ -229,7 +250,7 @@ "name": "" }, { - "id": 11, + "id": 12, "node_id": 1, "instance": 0, "minimum": -40, @@ -250,7 +271,7 @@ "name": "" }, { - "id": 12, + "id": 13, "node_id": 1, "instance": 0, "minimum": 0, @@ -271,7 +292,7 @@ "name": "" }, { - "id": 13, + "id": 14, "node_id": 1, "instance": 0, "minimum": 0, @@ -292,7 +313,7 @@ "name": "" }, { - "id": 14, + "id": 15, "node_id": 1, "instance": 0, "minimum": -64, @@ -313,7 +334,7 @@ "name": "" }, { - "id": 15, + "id": 16, "node_id": 1, "instance": 0, "minimum": 0, @@ -334,7 +355,7 @@ "name": "" }, { - "id": 16, + "id": 17, "node_id": 1, "instance": 0, "minimum": 0, @@ -355,7 +376,7 @@ "name": "" }, { - "id": 17, + "id": 18, "node_id": 1, "instance": 0, "minimum": 0, @@ -376,7 +397,7 @@ "name": "" }, { - "id": 18, + "id": 19, "node_id": 1, "instance": 0, "minimum": 0, @@ -397,7 +418,7 @@ "name": "" }, { - "id": 19, + "id": 20, "node_id": 1, "instance": 0, "minimum": 0, @@ -418,7 +439,7 @@ "name": "" }, { - "id": 20, + "id": 21, "node_id": 1, "instance": 0, "minimum": -64, @@ -439,7 +460,7 @@ "name": "" }, { - "id": 21, + "id": 22, "node_id": 1, "instance": 0, "minimum": 0, @@ -460,7 +481,7 @@ "name": "" }, { - "id": 22, + "id": 23, "node_id": 1, "instance": 0, "minimum": 0, @@ -481,7 +502,7 @@ "name": "" }, { - "id": 23, + "id": 24, "node_id": 1, "instance": 0, "minimum": -50, @@ -502,7 +523,7 @@ "name": "" }, { - "id": 24, + "id": 25, "node_id": 1, "instance": 0, "minimum": 0, @@ -523,7 +544,7 @@ "name": "" }, { - "id": 25, + "id": 26, "node_id": 1, "instance": 0, "minimum": 0, @@ -544,7 +565,7 @@ "name": "" }, { - "id": 26, + "id": 27, "node_id": 1, "instance": 0, "minimum": 0, @@ -565,7 +586,7 @@ "name": "" }, { - "id": 27, + "id": 28, "node_id": 1, "instance": 0, "minimum": 0, @@ -586,7 +607,7 @@ "name": "" }, { - "id": 28, + "id": 29, "node_id": 1, "instance": 0, "minimum": 0, @@ -607,7 +628,7 @@ "name": "" }, { - "id": 29, + "id": 30, "node_id": 1, "instance": 0, "minimum": 0, @@ -628,7 +649,7 @@ "name": "" }, { - "id": 30, + "id": 31, "node_id": 1, "instance": 1, "minimum": 0, @@ -649,7 +670,7 @@ "name": "" }, { - "id": 31, + "id": 32, "node_id": 1, "instance": 2, "minimum": 0, @@ -670,7 +691,7 @@ "name": "" }, { - "id": 32, + "id": 33, "node_id": 1, "instance": 0, "minimum": 0, @@ -691,7 +712,7 @@ "name": "" }, { - "id": 33, + "id": 34, "node_id": 1, "instance": 0, "minimum": 0, @@ -712,7 +733,7 @@ "name": "" }, { - "id": 34, + "id": 35, "node_id": 1, "instance": 0, "minimum": -50, @@ -740,7 +761,7 @@ } }, { - "id": 35, + "id": 36, "node_id": 1, "instance": 0, "minimum": -50, diff --git a/tests/components/homee/snapshots/test_sensor.ambr b/tests/components/homee/snapshots/test_sensor.ambr index 5772bdc128b6c7..ca8f66c89f25f9 100644 --- a/tests/components/homee/snapshots/test_sensor.ambr +++ b/tests/components/homee/snapshots/test_sensor.ambr @@ -90,7 +90,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_instance', - 'unique_id': '00055511EECC-1-7', + 'unique_id': '00055511EECC-1-8', 'unit_of_measurement': , }) # --- @@ -147,7 +147,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_instance', - 'unique_id': '00055511EECC-1-8', + 'unique_id': '00055511EECC-1-9', 'unit_of_measurement': , }) # --- @@ -201,7 +201,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dawn', - 'unique_id': '00055511EECC-1-10', + 'unique_id': '00055511EECC-1-11', 'unit_of_measurement': 'lx', }) # --- @@ -258,7 +258,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_temperature', - 'unique_id': '00055511EECC-1-11', + 'unique_id': '00055511EECC-1-12', 'unit_of_measurement': , }) # --- @@ -426,7 +426,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'exhaust_motor_revs', - 'unique_id': '00055511EECC-1-12', + 'unique_id': '00055511EECC-1-13', 'unit_of_measurement': 'rpm', }) # --- @@ -482,7 +482,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'external_temperature', - 'unique_id': '00055511EECC-1-34', + 'unique_id': '00055511EECC-1-35', 'unit_of_measurement': , }) # --- @@ -539,7 +539,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'floor_temperature', - 'unique_id': '00055511EECC-1-35', + 'unique_id': '00055511EECC-1-36', 'unit_of_measurement': , }) # --- @@ -593,7 +593,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity', - 'unique_id': '00055511EECC-1-22', + 'unique_id': '00055511EECC-1-23', 'unit_of_measurement': '%', }) # --- @@ -720,6 +720,60 @@ 'state': '175.0', }) # --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_1_2-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.test_multisensor_illuminance_1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Illuminance 1', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brightness_instance', + 'unique_id': '00055511EECC-1-6', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Test MultiSensor Illuminance 1', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_illuminance_1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '175.0', + }) +# --- # name: test_sensor_snapshot[sensor.test_multisensor_illuminance_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -754,7 +808,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'brightness_instance', - 'unique_id': '00055511EECC-1-6', + 'unique_id': '00055511EECC-1-7', 'unit_of_measurement': 'lx', }) # --- @@ -808,7 +862,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indoor_humidity', - 'unique_id': '00055511EECC-1-13', + 'unique_id': '00055511EECC-1-14', 'unit_of_measurement': '%', }) # --- @@ -865,7 +919,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indoor_temperature', - 'unique_id': '00055511EECC-1-14', + 'unique_id': '00055511EECC-1-15', 'unit_of_measurement': , }) # --- @@ -919,7 +973,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'intake_motor_revs', - 'unique_id': '00055511EECC-1-15', + 'unique_id': '00055511EECC-1-16', 'unit_of_measurement': 'rpm', }) # --- @@ -975,7 +1029,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'level', - 'unique_id': '00055511EECC-1-16', + 'unique_id': '00055511EECC-1-17', 'unit_of_measurement': , }) # --- @@ -1029,7 +1083,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'link_quality', - 'unique_id': '00055511EECC-1-17', + 'unique_id': '00055511EECC-1-18', 'unit_of_measurement': None, }) # --- @@ -1167,7 +1221,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'operating_hours', - 'unique_id': '00055511EECC-1-18', + 'unique_id': '00055511EECC-1-19', 'unit_of_measurement': , }) # --- @@ -1221,7 +1275,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outdoor_humidity', - 'unique_id': '00055511EECC-1-19', + 'unique_id': '00055511EECC-1-20', 'unit_of_measurement': '%', }) # --- @@ -1278,7 +1332,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outdoor_temperature', - 'unique_id': '00055511EECC-1-20', + 'unique_id': '00055511EECC-1-21', 'unit_of_measurement': , }) # --- @@ -1332,7 +1386,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'position', - 'unique_id': '00055511EECC-1-21', + 'unique_id': '00055511EECC-1-22', 'unit_of_measurement': '%', }) # --- @@ -1391,7 +1445,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'up_down', - 'unique_id': '00055511EECC-1-28', + 'unique_id': '00055511EECC-1-29', 'unit_of_measurement': None, }) # --- @@ -1453,7 +1507,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', - 'unique_id': '00055511EECC-1-23', + 'unique_id': '00055511EECC-1-24', 'unit_of_measurement': , }) # --- @@ -1510,7 +1564,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_current', - 'unique_id': '00055511EECC-1-25', + 'unique_id': '00055511EECC-1-26', 'unit_of_measurement': , }) # --- @@ -1567,7 +1621,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy', - 'unique_id': '00055511EECC-1-24', + 'unique_id': '00055511EECC-1-25', 'unit_of_measurement': , }) # --- @@ -1624,7 +1678,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_power', - 'unique_id': '00055511EECC-1-26', + 'unique_id': '00055511EECC-1-27', 'unit_of_measurement': , }) # --- @@ -1681,7 +1735,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_voltage', - 'unique_id': '00055511EECC-1-27', + 'unique_id': '00055511EECC-1-28', 'unit_of_measurement': , }) # --- @@ -1735,7 +1789,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uv', - 'unique_id': '00055511EECC-1-29', + 'unique_id': '00055511EECC-1-30', 'unit_of_measurement': None, }) # --- @@ -1790,7 +1844,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_instance', - 'unique_id': '00055511EECC-1-30', + 'unique_id': '00055511EECC-1-31', 'unit_of_measurement': , }) # --- @@ -1847,7 +1901,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_instance', - 'unique_id': '00055511EECC-1-31', + 'unique_id': '00055511EECC-1-32', 'unit_of_measurement': , }) # --- @@ -1907,7 +1961,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_speed', - 'unique_id': '00055511EECC-1-32', + 'unique_id': '00055511EECC-1-33', 'unit_of_measurement': , }) # --- @@ -1965,7 +2019,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'window_position', - 'unique_id': '00055511EECC-1-33', + 'unique_id': '00055511EECC-1-34', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/homee/test_sensor.py b/tests/components/homee/test_sensor.py index 0e7bde2e76b5f8..0059b4ceedb78e 100644 --- a/tests/components/homee/test_sensor.py +++ b/tests/components/homee/test_sensor.py @@ -49,7 +49,7 @@ async def test_up_down_values( assert hass.states.get("sensor.test_multisensor_state").state == OPEN_CLOSE_MAP[0] - attribute = mock_homee.nodes[0].attributes[27] + attribute = mock_homee.nodes[0].attributes[28] for i in range(1, 5): await async_update_attribute_value(hass, attribute, i) assert ( @@ -79,7 +79,7 @@ async def test_window_position( == WINDOW_MAP[0] ) - attribute = mock_homee.nodes[0].attributes[32] + attribute = mock_homee.nodes[0].attributes[33] for i in range(1, 3): await async_update_attribute_value(hass, attribute, i) assert ( @@ -137,7 +137,7 @@ async def test_entity_update_action( blocking=True, ) - mock_homee.update_attribute.assert_called_once_with(1, 23) + mock_homee.update_attribute.assert_called_once_with(1, 24) async def test_sensor_snapshot( diff --git a/tests/components/html5/conftest.py b/tests/components/html5/conftest.py index 9c5322b94a67a4..d24e3102142eed 100644 --- a/tests/components/html5/conftest.py +++ b/tests/components/html5/conftest.py @@ -1,8 +1,9 @@ """Common fixtures for html5 integration.""" from collections.abc import Generator -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock +from aiohttp import ClientResponse import pytest from homeassistant.components.html5.const import ( @@ -45,3 +46,58 @@ def mock_load_config() -> Generator[MagicMock]: "homeassistant.components.html5.notify._load_config", return_value={} ) as mock_load_config: yield mock_load_config + + +@pytest.fixture +def mock_wp() -> Generator[AsyncMock]: + """Mock WebPusher.""" + + with ( + patch( + "homeassistant.components.html5.notify.WebPusher", autospec=True + ) as mock_client, + ): + client = mock_client.return_value + client.cls = mock_client + client.send_async.return_value = AsyncMock(spec=ClientResponse, status=201) + yield client + + +@pytest.fixture +def mock_jwt() -> Generator[MagicMock]: + """Mock JWT.""" + + with ( + patch("homeassistant.components.html5.notify.jwt") as mock_client, + ): + mock_client.encode.return_value = "JWT" + mock_client.decode.return_value = {"target": "device"} + yield mock_client + + +@pytest.fixture +def mock_uuid() -> Generator[MagicMock]: + """Mock UUID.""" + + with ( + patch("homeassistant.components.html5.notify.uuid") as mock_client, + ): + mock_client.uuid4.return_value = "12345678-1234-5678-1234-567812345678" + yield mock_client + + +@pytest.fixture +def mock_vapid() -> Generator[MagicMock]: + """Mock VAPID headers.""" + + with ( + patch( + "homeassistant.components.html5.notify.Vapid", autospec=True + ) as mock_client, + ): + mock_client.from_string.return_value.sign.return_value = { + "Authorization": "vapid t=signed!!!", + "urgency": "normal", + "priority": "normal", + } + yield mock_client diff --git a/tests/components/html5/test_notify.py b/tests/components/html5/test_notify.py index d1d37cc0e164dc..3861cca25cd67e 100644 --- a/tests/components/html5/test_notify.py +++ b/tests/components/html5/test_notify.py @@ -2,7 +2,7 @@ from http import HTTPStatus import json -from unittest.mock import MagicMock, mock_open, patch +from unittest.mock import AsyncMock, MagicMock, mock_open, patch from aiohttp.hdrs import AUTHORIZATION import pytest @@ -71,6 +71,12 @@ REGISTER_URL = "/api/notify.html5" PUBLISH_URL = "/api/notify.html5/callback" +VAPID_HEADERS = { + "Authorization": "vapid t=signed!!!", + "urgency": "normal", + "priority": "normal", +} + async def test_get_service_with_no_json(hass: HomeAssistant) -> None: """Test empty json file.""" @@ -82,11 +88,11 @@ async def test_get_service_with_no_json(hass: HomeAssistant) -> None: assert service is not None -@patch("homeassistant.components.html5.notify.WebPusher") -async def test_dismissing_message(mock_wp, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_jwt", "mock_vapid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_dismissing_message(mock_wp: AsyncMock, hass: HomeAssistant) -> None: """Test dismissing message.""" await async_setup_component(hass, "http", {}) - mock_wp().send().status_code = 201 data = {"device": SUBSCRIPTION_1} @@ -99,23 +105,18 @@ async def test_dismissing_message(mock_wp, hass: HomeAssistant) -> None: await service.async_dismiss(target=["device", "non_existing"], data={"tag": "test"}) - assert len(mock_wp.mock_calls) == 4 - - # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_1["subscription"] - - # Call to send - payload = json.loads(mock_wp.mock_calls[3][2]["data"]) - - assert payload["dismiss"] is True - assert payload["tag"] == "test" + mock_wp.send_async.assert_awaited_once_with( + data='{"tag": "test", "dismiss": true, "data": {"jwt": "JWT"}, "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) -@patch("homeassistant.components.html5.notify.WebPusher") -async def test_sending_message(mock_wp, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_sending_message(mock_wp: AsyncMock, hass: HomeAssistant) -> None: """Test sending message.""" await async_setup_component(hass, "http", {}) - mock_wp().send().status_code = 201 data = {"device": SUBSCRIPTION_1} @@ -130,23 +131,21 @@ async def test_sending_message(mock_wp, hass: HomeAssistant) -> None: "Hello", target=["device", "non_existing"], data={"icon": "beer.png"} ) - assert len(mock_wp.mock_calls) == 4 + mock_wp.send_async.assert_awaited_once_with( + data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "beer.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_1["subscription"] - - # Call to send - payload = json.loads(mock_wp.mock_calls[3][2]["data"]) + assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_1["subscription"] - assert payload["body"] == "Hello" - assert payload["icon"] == "beer.png" - -@patch("homeassistant.components.html5.notify.WebPusher") -async def test_fcm_key_include(mock_wp, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_fcm_key_include(mock_wp: AsyncMock, hass: HomeAssistant) -> None: """Test if the FCM header is included.""" await async_setup_component(hass, "http", {}) - mock_wp().send().status_code = 201 data = {"chrome": SUBSCRIPTION_5} @@ -159,19 +158,23 @@ async def test_fcm_key_include(mock_wp, hass: HomeAssistant) -> None: await service.async_send_message("Hello", target=["chrome"]) - assert len(mock_wp.mock_calls) == 4 - # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_5["subscription"] + mock_wp.send_async.assert_awaited_once_with( + data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "/static/icons/favicon-192x192.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) - # Get the keys passed to the WebPusher's send method - assert mock_wp.mock_calls[3][2]["headers"]["Authorization"] is not None + # WebPusher constructor + assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_5["subscription"] -@patch("homeassistant.components.html5.notify.WebPusher") -async def test_fcm_send_with_unknown_priority(mock_wp, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_fcm_send_with_unknown_priority( + mock_wp: AsyncMock, hass: HomeAssistant +) -> None: """Test if the gcm_key is only included for GCM endpoints.""" await async_setup_component(hass, "http", {}) - mock_wp().send().status_code = 201 data = {"chrome": SUBSCRIPTION_5} @@ -184,19 +187,20 @@ async def test_fcm_send_with_unknown_priority(mock_wp, hass: HomeAssistant) -> N await service.async_send_message("Hello", target=["chrome"], priority="undefined") - assert len(mock_wp.mock_calls) == 4 + mock_wp.send_async.assert_awaited_once_with( + data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "/static/icons/favicon-192x192.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_5["subscription"] - - # Get the keys passed to the WebPusher's send method - assert mock_wp.mock_calls[3][2]["headers"]["priority"] == "normal" + assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_5["subscription"] -@patch("homeassistant.components.html5.notify.WebPusher") -async def test_fcm_no_targets(mock_wp, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_fcm_no_targets(mock_wp: AsyncMock, hass: HomeAssistant) -> None: """Test if the gcm_key is only included for GCM endpoints.""" await async_setup_component(hass, "http", {}) - mock_wp().send().status_code = 201 data = {"chrome": SUBSCRIPTION_5} @@ -209,19 +213,20 @@ async def test_fcm_no_targets(mock_wp, hass: HomeAssistant) -> None: await service.async_send_message("Hello") - assert len(mock_wp.mock_calls) == 4 + mock_wp.send_async.assert_awaited_once_with( + data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "/static/icons/favicon-192x192.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_5["subscription"] + assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_5["subscription"] - # Get the keys passed to the WebPusher's send method - assert mock_wp.mock_calls[3][2]["headers"]["priority"] == "normal" - -@patch("homeassistant.components.html5.notify.WebPusher") -async def test_fcm_additional_data(mock_wp, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_fcm_additional_data(mock_wp: AsyncMock, hass: HomeAssistant) -> None: """Test if the gcm_key is only included for GCM endpoints.""" await async_setup_component(hass, "http", {}) - mock_wp().send().status_code = 201 data = {"chrome": SUBSCRIPTION_5} @@ -234,12 +239,13 @@ async def test_fcm_additional_data(mock_wp, hass: HomeAssistant) -> None: await service.async_send_message("Hello", data={"mykey": "myvalue"}) - assert len(mock_wp.mock_calls) == 4 + mock_wp.send_async.assert_awaited_once_with( + data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"mykey": "myvalue", "url": "/", "jwt": "JWT"}, "icon": "/static/icons/favicon-192x192.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_5["subscription"] - - # Get the keys passed to the WebPusher's send method - assert mock_wp.mock_calls[3][2]["headers"]["priority"] == "normal" + assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_5["subscription"] @pytest.mark.usefixtures("load_config") @@ -581,11 +587,14 @@ async def test_callback_view_no_jwt( assert resp.status == HTTPStatus.UNAUTHORIZED +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") async def test_callback_view_with_jwt( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, load_config: MagicMock, + mock_wp: AsyncMock, ) -> None: """Test that the notification callback view works with JWT.""" load_config.return_value = {"device": SUBSCRIPTION_1} @@ -599,27 +608,22 @@ async def test_callback_view_with_jwt( client = await hass_client() - with patch("homeassistant.components.html5.notify.WebPusher") as mock_wp: - mock_wp().send().status_code = 201 - await hass.services.async_call( - "notify", - "html5", - {"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}}, - blocking=True, - ) - - assert len(mock_wp.mock_calls) == 4 + await hass.services.async_call( + "notify", + "html5", + {"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}}, + blocking=True, + ) + mock_wp.send_async.assert_awaited_once_with( + data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "beer.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_1["subscription"] - - # Call to send - push_payload = json.loads(mock_wp.mock_calls[3][2]["data"]) - - assert push_payload["body"] == "Hello" - assert push_payload["icon"] == "beer.png" + assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_1["subscription"] - bearer_token = f"Bearer {push_payload['data']['jwt']}" + bearer_token = "Bearer JWT" resp = await client.post( PUBLISH_URL, json={"type": "push"}, headers={AUTHORIZATION: bearer_token} @@ -630,10 +634,13 @@ async def test_callback_view_with_jwt( assert body == {"event": "push", "status": "ok"} +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") async def test_send_fcm_without_targets( hass: HomeAssistant, config_entry: MockConfigEntry, load_config: MagicMock, + mock_wp: AsyncMock, ) -> None: """Test that the notification is send with FCM without targets.""" load_config.return_value = {"device": SUBSCRIPTION_5} @@ -645,25 +652,29 @@ async def test_send_fcm_without_targets( assert config_entry.state is ConfigEntryState.LOADED - with patch("homeassistant.components.html5.notify.WebPusher") as mock_wp: - mock_wp().send().status_code = 201 - await hass.services.async_call( - "notify", - "html5", - {"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}}, - blocking=True, - ) - - assert len(mock_wp.mock_calls) == 4 + await hass.services.async_call( + "notify", + "html5", + {"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}}, + blocking=True, + ) + mock_wp.send_async.assert_awaited_once_with( + data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "beer.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_5["subscription"] + assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_5["subscription"] +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") async def test_send_fcm_expired( hass: HomeAssistant, config_entry: MockConfigEntry, load_config: MagicMock, + mock_wp: AsyncMock, ) -> None: """Test that the FCM target is removed when expired.""" load_config.return_value = {"device": SUBSCRIPTION_5} @@ -674,12 +685,10 @@ async def test_send_fcm_expired( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - + mock_wp.send_async.return_value.status = 410 with ( - patch("homeassistant.components.html5.notify.WebPusher") as mock_wp, patch("homeassistant.components.html5.notify.save_json") as mock_save, ): - mock_wp().send().status_code = 410 await hass.services.async_call( "notify", "html5", @@ -690,11 +699,14 @@ async def test_send_fcm_expired( mock_save.assert_called_once_with(hass.config.path(html5.REGISTRATIONS_FILE), {}) +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") async def test_send_fcm_expired_save_fails( hass: HomeAssistant, config_entry: MockConfigEntry, load_config: MagicMock, caplog: pytest.LogCaptureFixture, + mock_wp: AsyncMock, ) -> None: """Test that the FCM target remains after expiry if save_json fails.""" load_config.return_value = {"device": SUBSCRIPTION_5} @@ -705,16 +717,13 @@ async def test_send_fcm_expired_save_fails( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - + mock_wp.send_async.return_value.status = 410 with ( - patch("homeassistant.components.html5.notify.WebPusher") as mock_wp, patch( "homeassistant.components.html5.notify.save_json", side_effect=HomeAssistantError(), ), ): - mock_wp().send().status_code = 410 - await hass.services.async_call( "notify", "html5", diff --git a/tests/components/knx/test_sensor.py b/tests/components/knx/test_sensor.py index ff42d78fd2cc8c..9fb3b85b9f2adb 100644 --- a/tests/components/knx/test_sensor.py +++ b/tests/components/knx/test_sensor.py @@ -1,5 +1,6 @@ """Test KNX sensor.""" +import logging from typing import Any from freezegun.api import FrozenDateTimeFactory @@ -11,6 +12,11 @@ CONF_SYNC_STATE, ) from homeassistant.components.knx.schema import SensorSchema +from homeassistant.components.sensor import ( + CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.const import CONF_NAME, CONF_TYPE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant, State @@ -42,13 +48,18 @@ async def test_sensor(hass: HomeAssistant, knx: KNXTestKit) -> None: # StateUpdater initialize state await knx.assert_read("1/1/1") await knx.receive_response("1/1/1", (0, 40)) - state = hass.states.get("sensor.test") - assert state.state == "40" + knx.assert_state( + "sensor.test", + "40", + # default values for DPT type "current" + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + unit_of_measurement="mA", + ) # update from KNX await knx.receive_write("1/1/1", (0x03, 0xE8)) - state = hass.states.get("sensor.test") - assert state.state == "1000" + knx.assert_state("sensor.test", "1000") # don't answer to GroupValueRead requests await knx.receive_read("1/1/1") @@ -172,6 +183,38 @@ async def test_always_callback(hass: HomeAssistant, knx: KNXTestKit) -> None: assert len(events) == 6 +async def test_sensor_yaml_attribute_validation( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + knx: KNXTestKit, +) -> None: + """Test creating a sensor with invalid unit, state_class or device_class.""" + with caplog.at_level(logging.ERROR): + await knx.setup_integration( + { + SensorSchema.PLATFORM: { + CONF_NAME: "test", + CONF_STATE_ADDRESS: "1/1/1", + CONF_TYPE: "9.001", # temperature 2 byte float + CONF_SENSOR_STATE_CLASS: "total_increasing", # invalid for temperature + } + } + ) + assert len(caplog.messages) == 2 + record = caplog.records[0] + assert record.levelname == "ERROR" + assert ( + "Invalid config for 'knx': State class 'total_increasing' is not valid for device class" + in record.message + ) + + record = caplog.records[1] + assert record.levelname == "ERROR" + assert "Setup failed for 'knx': Invalid config." in record.message + + assert hass.states.get("sensor.test") is None + + @pytest.mark.parametrize( ("knx_config", "response_payload", "expected_state"), [ @@ -186,8 +229,8 @@ async def test_always_callback(hass: HomeAssistant, knx: KNXTestKit) -> None: (0, 0), { "state": "0.0", - "device_class": "temperature", - "state_class": "measurement", + "device_class": SensorDeviceClass.TEMPERATURE, + "state_class": SensorStateClass.MEASUREMENT, "unit_of_measurement": "°C", }, ), @@ -206,8 +249,8 @@ async def test_always_callback(hass: HomeAssistant, knx: KNXTestKit) -> None: (1, 2, 3, 4), { "state": "16909060", - "device_class": "energy", - "state_class": "total_increasing", + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL_INCREASING, }, ), ], diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py index b6ce4d609544ac..5fcb49e1b581de 100644 --- a/tests/components/litterrobot/test_sensor.py +++ b/tests/components/litterrobot/test_sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.litterrobot.sensor import icon_for_gauge_level from homeassistant.components.sensor import ( - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorStateClass, ) @@ -24,7 +24,7 @@ async def test_waste_drawer_sensor( hass: HomeAssistant, mock_account: MagicMock ) -> None: """Tests the waste drawer sensor entity was set up.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, SENSOR_DOMAIN) sensor = hass.states.get(WASTE_DRAWER_ENTITY_ID) assert sensor @@ -36,9 +36,7 @@ async def test_sleep_time_sensor_with_sleep_disabled( hass: HomeAssistant, mock_account_with_sleep_disabled_robot: MagicMock ) -> None: """Tests the sleep mode start time sensor where sleep mode is disabled.""" - await setup_integration( - hass, mock_account_with_sleep_disabled_robot, PLATFORM_DOMAIN - ) + await setup_integration(hass, mock_account_with_sleep_disabled_robot, SENSOR_DOMAIN) sensor = hass.states.get(SLEEP_START_TIME_ENTITY_ID) assert sensor @@ -79,7 +77,7 @@ async def test_litter_robot_sensor( hass: HomeAssistant, mock_account_with_litterrobot_4: MagicMock ) -> None: """Tests Litter-Robot sensors.""" - await setup_integration(hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_litterrobot_4, SENSOR_DOMAIN) sensor = hass.states.get(SLEEP_START_TIME_ENTITY_ID) assert sensor.state == "2022-09-19T04:00:00+00:00" @@ -109,7 +107,7 @@ async def test_feeder_robot_sensor( hass: HomeAssistant, mock_account_with_feederrobot: MagicMock ) -> None: """Tests Feeder-Robot sensors.""" - await setup_integration(hass, mock_account_with_feederrobot, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_feederrobot, SENSOR_DOMAIN) sensor = hass.states.get("sensor.test_food_level") assert sensor.state == "10" assert sensor.attributes["unit_of_measurement"] == PERCENTAGE @@ -133,7 +131,7 @@ async def test_pet_weight_sensor( hass: HomeAssistant, mock_account_with_pet: MagicMock ) -> None: """Tests pet weight sensors.""" - await setup_integration(hass, mock_account_with_pet, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_pet, SENSOR_DOMAIN) sensor = hass.states.get("sensor.kitty_weight") assert sensor.state == "9.1" assert sensor.attributes["unit_of_measurement"] == UnitOfMass.POUNDS @@ -144,7 +142,7 @@ async def test_pet_visits_today_sensor( hass: HomeAssistant, mock_account_with_pet: MagicMock ) -> None: """Tests pet visits today sensors.""" - await setup_integration(hass, mock_account_with_pet, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_pet, SENSOR_DOMAIN) sensor = hass.states.get("sensor.kitty_visits_today") assert sensor.state == "2" @@ -153,6 +151,6 @@ async def test_litterhopper_sensor( hass: HomeAssistant, mock_account_with_litterhopper: MagicMock ) -> None: """Tests LitterHopper sensors.""" - await setup_integration(hass, mock_account_with_litterhopper, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_litterhopper, SENSOR_DOMAIN) sensor = hass.states.get("sensor.test_hopper_status") assert sensor.state == "enabled" diff --git a/tests/components/litterrobot/test_switch.py b/tests/components/litterrobot/test_switch.py index 3991bdbbab0dfc..dea0b63496ab3f 100644 --- a/tests/components/litterrobot/test_switch.py +++ b/tests/components/litterrobot/test_switch.py @@ -7,7 +7,7 @@ from homeassistant.components.litterrobot import DOMAIN from homeassistant.components.switch import ( - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) @@ -25,7 +25,7 @@ async def test_switch( hass: HomeAssistant, mock_account: MagicMock, entity_registry: er.EntityRegistry ) -> None: """Tests the switch entity was set up.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, SWITCH_DOMAIN) state = hass.states.get(NIGHT_LIGHT_MODE_ENTITY_ID) assert state @@ -51,7 +51,7 @@ async def test_on_off_commands( updated_field: str, ) -> None: """Test sending commands to the switch.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, SWITCH_DOMAIN) robot: Robot = mock_account.robots[0] state = hass.states.get(entity_id) @@ -61,7 +61,7 @@ async def test_on_off_commands( services = ((SERVICE_TURN_ON, STATE_ON, "1"), (SERVICE_TURN_OFF, STATE_OFF, "0")) for count, (service, new_state, new_value) in enumerate(services): - await hass.services.async_call(PLATFORM_DOMAIN, service, data, blocking=True) + await hass.services.async_call(SWITCH_DOMAIN, service, data, blocking=True) robot._update_data({updated_field: new_value}, partial=True) assert getattr(robot, robot_command).call_count == count + 1 @@ -73,7 +73,7 @@ async def test_feeder_robot_switch( hass: HomeAssistant, mock_account_with_feederrobot: MagicMock ) -> None: """Tests Feeder-Robot switches.""" - await setup_integration(hass, mock_account_with_feederrobot, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_feederrobot, SWITCH_DOMAIN) robot: FeederRobot = mock_account_with_feederrobot.robots[0] gravity_mode_switch = "switch.test_gravity_mode" @@ -85,7 +85,7 @@ async def test_feeder_robot_switch( services = ((SERVICE_TURN_ON, STATE_ON, True), (SERVICE_TURN_OFF, STATE_OFF, False)) for count, (service, new_state, new_value) in enumerate(services): - await hass.services.async_call(PLATFORM_DOMAIN, service, data, blocking=True) + await hass.services.async_call(SWITCH_DOMAIN, service, data, blocking=True) robot._update_data({"state": {"info": {"gravity": new_value}}}, partial=True) assert robot.set_gravity_mode.call_count == count + 1 @@ -114,16 +114,16 @@ async def test_litterrobot_4_deprecated_switch( """Test switch deprecation issue.""" entity_uid = "LR4C010001-night_light_mode_enabled" if preexisting_entity: - suggested_id = NIGHT_LIGHT_MODE_ENTITY_ID.replace(f"{PLATFORM_DOMAIN}.", "") + suggested_id = NIGHT_LIGHT_MODE_ENTITY_ID.replace(f"{SWITCH_DOMAIN}.", "") entity_registry.async_get_or_create( - PLATFORM_DOMAIN, + SWITCH_DOMAIN, DOMAIN, entity_uid, suggested_object_id=suggested_id, disabled_by=disabled_by, ) - await setup_integration(hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_litterrobot_4, SWITCH_DOMAIN) assert ( entity_registry.async_get(NIGHT_LIGHT_MODE_ENTITY_ID) is not None diff --git a/tests/components/litterrobot/test_time.py b/tests/components/litterrobot/test_time.py index f77263d9493132..75dfc9e5ca4d1b 100644 --- a/tests/components/litterrobot/test_time.py +++ b/tests/components/litterrobot/test_time.py @@ -8,7 +8,7 @@ from pylitterbot import LitterRobot3 import pytest -from homeassistant.components.time import DOMAIN as PLATFORM_DOMAIN +from homeassistant.components.time import DOMAIN as TIME_DOMAIN from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant @@ -22,7 +22,7 @@ async def test_sleep_mode_start_time( hass: HomeAssistant, mock_account: MagicMock ) -> None: """Tests the sleep mode start time.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, TIME_DOMAIN) entity = hass.states.get(SLEEP_START_TIME_ENTITY_ID) assert entity @@ -30,7 +30,7 @@ async def test_sleep_mode_start_time( robot: LitterRobot3 = mock_account.robots[0] await hass.services.async_call( - PLATFORM_DOMAIN, + TIME_DOMAIN, "set_value", {ATTR_ENTITY_ID: SLEEP_START_TIME_ENTITY_ID, "time": time(23, 0)}, blocking=True, diff --git a/tests/components/litterrobot/test_update.py b/tests/components/litterrobot/test_update.py index f7d7492dec80e2..dccfec0b29e888 100644 --- a/tests/components/litterrobot/test_update.py +++ b/tests/components/litterrobot/test_update.py @@ -10,7 +10,7 @@ ATTR_INSTALLED_VERSION, ATTR_LATEST_VERSION, ATTR_RELEASE_URL, - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL, UpdateDeviceClass, ) @@ -40,7 +40,7 @@ async def test_robot_with_no_update( robot.get_latest_firmware = AsyncMock(return_value=None) entry = await setup_integration( - hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN + hass, mock_account_with_litterrobot_4, UPDATE_DOMAIN ) state = hass.states.get(ENTITY_ID) @@ -63,7 +63,7 @@ async def test_robot_with_update( robot.has_firmware_update = AsyncMock(return_value=True) robot.get_latest_firmware = AsyncMock(return_value=NEW_FIRMWARE) - await setup_integration(hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_litterrobot_4, UPDATE_DOMAIN) state = hass.states.get(ENTITY_ID) assert state @@ -77,7 +77,7 @@ async def test_robot_with_update( with pytest.raises(HomeAssistantError): await hass.services.async_call( - PLATFORM_DOMAIN, + UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, @@ -87,7 +87,7 @@ async def test_robot_with_update( robot.update_firmware = AsyncMock(return_value=True) await hass.services.async_call( - PLATFORM_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True ) await hass.async_block_till_done() assert robot.update_firmware.call_count == 1 @@ -101,7 +101,7 @@ async def test_robot_with_update_already_in_progress( robot._update_data({"isFirmwareUpdateTriggered": True}, partial=True) entry = await setup_integration( - hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN + hass, mock_account_with_litterrobot_4, UPDATE_DOMAIN ) state = hass.states.get(ENTITY_ID) diff --git a/tests/components/mastodon/test_config_flow.py b/tests/components/mastodon/test_config_flow.py index 5f1014c31d3d41..56f399b85dbc44 100644 --- a/tests/components/mastodon/test_config_flow.py +++ b/tests/components/mastodon/test_config_flow.py @@ -1,6 +1,6 @@ """Tests for the Mastodon config flow.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from mastodon.Mastodon import ( MastodonNetworkError, @@ -204,3 +204,206 @@ async def test_duplicate( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_TOKEN: "token2"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_ACCESS_TOKEN] == "token2" + + +async def test_reauth_flow_wrong_account( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with wrong account.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.mastodon.config_flow.construct_mastodon_username", + return_value="BAD_USERNAME", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_TOKEN: "token2"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_account" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (MastodonNetworkError, "network_error"), + (MastodonUnauthorizedError, "unauthorized_error"), + (Exception, "unknown"), + ], +) +async def test_reauth_flow_exceptions( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reauth flow errors.""" + mock_config_entry.add_to_hass(hass) + mock_mastodon_client.account_verify_credentials.side_effect = exception + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_TOKEN: "token"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} + + mock_mastodon_client.account_verify_credentials.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_TOKEN: "token"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLIENT_ID: "client_id2", + CONF_CLIENT_SECRET: "client_secret2", + CONF_ACCESS_TOKEN: "access_token2", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_BASE_URL] == "https://mastodon.social" + assert mock_config_entry.data[CONF_CLIENT_ID] == "client_id2" + assert mock_config_entry.data[CONF_CLIENT_SECRET] == "client_secret2" + assert mock_config_entry.data[CONF_ACCESS_TOKEN] == "access_token2" + + +async def test_reconfigure_flow_wrong_account( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow with wrong account.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + with patch( + "homeassistant.components.mastodon.config_flow.construct_mastodon_username", + return_value="WRONG_USERNAME", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLIENT_ID: "client_id", + CONF_CLIENT_SECRET: "client_secret", + CONF_ACCESS_TOKEN: "access_token2", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_account" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (MastodonNetworkError, "network_error"), + (MastodonUnauthorizedError, "unauthorized_error"), + (Exception, "unknown"), + ], +) +async def test_reconfigure_flow_exceptions( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reconfigure flow errors.""" + mock_config_entry.add_to_hass(hass) + mock_mastodon_client.account_verify_credentials.side_effect = exception + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLIENT_ID: "client_id", + CONF_CLIENT_SECRET: "client_secret", + CONF_ACCESS_TOKEN: "access_token", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": error} + + mock_mastodon_client.account_verify_credentials.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLIENT_ID: "client_id", + CONF_CLIENT_SECRET: "client_secret", + CONF_ACCESS_TOKEN: "access_token", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" diff --git a/tests/components/mastodon/test_init.py b/tests/components/mastodon/test_init.py index b4808792f66346..af6786a72883f7 100644 --- a/tests/components/mastodon/test_init.py +++ b/tests/components/mastodon/test_init.py @@ -2,7 +2,8 @@ from unittest.mock import AsyncMock -from mastodon.Mastodon import MastodonNotFoundError +from mastodon.Mastodon import MastodonNotFoundError, MastodonUnauthorizedError +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.mastodon.config_flow import MastodonConfigFlow @@ -33,18 +34,27 @@ async def test_device_info( assert device_entry == snapshot +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + (MastodonNotFoundError, ConfigEntryState.SETUP_RETRY), + (MastodonUnauthorizedError, ConfigEntryState.SETUP_ERROR), + ], +) async def test_initialization_failure( hass: HomeAssistant, mock_mastodon_client: AsyncMock, mock_config_entry: MockConfigEntry, + exception: Exception, + expected_state: ConfigEntryState, ) -> None: """Test initialization failure.""" - mock_mastodon_client.instance_v1.side_effect = MastodonNotFoundError - mock_mastodon_client.instance_v2.side_effect = MastodonNotFoundError + mock_mastodon_client.instance_v1.side_effect = exception + mock_mastodon_client.instance_v2.side_effect = exception await setup_integration(hass, mock_config_entry) - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry.state is expected_state async def test_setup_integration_fallback_to_instance_v1( diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index 6cc2e60882bb6c..1b3506e9422710 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -1637,6 +1637,159 @@ }), }) # --- +# name: test_service_get_shopping_list_items + dict({ + 'todo.mealie_supermarket': dict({ + 'items': list([ + dict({ + 'checked': False, + 'disable_amount': None, + 'display': '2 Apples', + 'food': None, + 'food_id': None, + 'is_food': None, + 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', + 'label': None, + 'label_id': None, + 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', + 'note': 'Apples', + 'position': 0, + 'quantity': 2.0, + 'unit': None, + 'unit_id': None, + }), + dict({ + 'checked': False, + 'disable_amount': False, + 'display': '1 can acorn squash', + 'food': dict({ + 'aliases': list([ + ]), + 'created_at': datetime.datetime(2024, 5, 14, 14, 45, 4, 454134), + 'description': '', + 'extras': dict({ + }), + 'food_id': '09322430-d24c-4b1a-abb6-22b6ed3a88f5', + 'households_with_ingredient_food': None, + 'label': None, + 'label_id': None, + 'name': 'acorn squash', + 'plural_name': None, + 'updated_at': datetime.datetime(2024, 5, 14, 14, 45, 4, 454141), + }), + 'food_id': '09322430-d24c-4b1a-abb6-22b6ed3a88f5', + 'is_food': True, + 'item_id': '84d8fd74-8eb0-402e-84b6-71f251bfb7cc', + 'label': None, + 'label_id': None, + 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', + 'note': '', + 'position': 1, + 'quantity': 1.0, + 'unit': dict({ + 'abbreviation': '', + 'aliases': list([ + ]), + 'created_at': datetime.datetime(2024, 5, 14, 14, 45, 2, 464122), + 'description': '', + 'extras': dict({ + }), + 'fraction': True, + 'name': 'can', + 'plural_abbreviation': '', + 'plural_name': None, + 'unit_id': '7bf539d4-fc78-48bc-b48e-c35ccccec34a', + 'updated_at': datetime.datetime(2024, 5, 14, 14, 45, 2, 464124), + 'use_abbreviation': False, + }), + 'unit_id': '7bf539d4-fc78-48bc-b48e-c35ccccec34a', + }), + dict({ + 'checked': False, + 'disable_amount': False, + 'display': 'aubergine', + 'food': dict({ + 'aliases': list([ + ]), + 'created_at': datetime.datetime(2024, 5, 14, 14, 45, 3, 868792), + 'description': '', + 'extras': dict({ + }), + 'food_id': '96801494-4e26-4148-849a-8155deb76327', + 'households_with_ingredient_food': None, + 'label': None, + 'label_id': None, + 'name': 'aubergine', + 'plural_name': None, + 'updated_at': datetime.datetime(2024, 5, 14, 14, 45, 3, 868794), + }), + 'food_id': '96801494-4e26-4148-849a-8155deb76327', + 'is_food': True, + 'item_id': '69913b9a-7c75-4935-abec-297cf7483f88', + 'label': None, + 'label_id': None, + 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', + 'note': '', + 'position': 2, + 'quantity': 0.0, + 'unit': None, + 'unit_id': None, + }), + dict({ + 'checked': False, + 'disable_amount': None, + 'display': '1 US cup flour', + 'food': dict({ + 'aliases': list([ + ]), + 'created_at': datetime.datetime(2024, 8, 25, 13, 29, 29, 40354, tzinfo=datetime.timezone.utc), + 'description': '', + 'extras': dict({ + }), + 'food_id': '8d2ef4d7-bfc2-4420-9cba-152016c1ee7c', + 'households_with_ingredient_food': list([ + ]), + 'label': None, + 'label_id': None, + 'name': 'flour', + 'plural_name': None, + 'updated_at': datetime.datetime(2024, 8, 25, 13, 29, 29, 40371, tzinfo=datetime.timezone.utc), + }), + 'food_id': '8d2ef4d7-bfc2-4420-9cba-152016c1ee7c', + 'is_food': None, + 'item_id': '22b389bb-e079-481c-915d-394e5edb20a5', + 'label': dict({ + 'label_id': '0e55cae5-6037-4cbb-8d4f-1042cbb83fd0', + 'name': 'Household', + }), + 'label_id': None, + 'list_id': 'a33af640-4704-453c-ab03-a95a393bf1c4', + 'note': '', + 'position': 0, + 'quantity': 1.0, + 'unit': dict({ + 'abbreviation': 'US cup', + 'aliases': list([ + ]), + 'created_at': datetime.datetime(2024, 8, 25, 13, 29, 25, 477518, tzinfo=datetime.timezone.utc), + 'description': '', + 'extras': dict({ + }), + 'fraction': True, + 'name': 'US cup', + 'plural_abbreviation': '', + 'plural_name': None, + 'unit_id': '89765d44-8412-4ab5-a6de-594aa8eac44c', + 'updated_at': datetime.datetime(2024, 8, 25, 13, 29, 25, 477535, tzinfo=datetime.timezone.utc), + 'use_abbreviation': False, + }), + 'unit_id': '89765d44-8412-4ab5-a6de-594aa8eac44c', + }), + ]), + 'name': 'Supermarket', + }), + }) +# --- # name: test_service_import_recipe dict({ 'recipe': dict({ diff --git a/tests/components/mealie/test_services.py b/tests/components/mealie/test_services.py index 0c31d783ceee33..957a219f901400 100644 --- a/tests/components/mealie/test_services.py +++ b/tests/components/mealie/test_services.py @@ -31,6 +31,7 @@ SERVICE_GET_MEALPLAN, SERVICE_GET_RECIPE, SERVICE_GET_RECIPES, + SERVICE_GET_SHOPPING_LIST_ITEMS, SERVICE_IMPORT_RECIPE, SERVICE_SET_MEALPLAN, SERVICE_SET_RANDOM_MEALPLAN, @@ -395,6 +396,47 @@ async def test_service_set_mealplan_invalid_entry_type( mock_mealie_client.set_mealplan.assert_not_called() +async def test_service_get_shopping_list_items( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the get_shopping_list_items service.""" + + await setup_integration(hass, mock_config_entry) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_SHOPPING_LIST_ITEMS, + target={"entity_id": "todo.mealie_supermarket"}, + blocking=True, + return_response=True, + ) + assert response == snapshot + + +async def test_service_get_shopping_list_items_connection_error( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the get_shopping_list_items service with connection error.""" + + await setup_integration(hass, mock_config_entry) + + mock_mealie_client.get_shopping_items.side_effect = MealieConnectionError + + with pytest.raises(HomeAssistantError, match="Error connecting to Mealie instance"): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_SHOPPING_LIST_ITEMS, + target={"entity_id": "todo.mealie_supermarket"}, + blocking=True, + return_response=True, + ) + + @pytest.mark.parametrize( ("service", "payload", "function", "exception", "raised_exception", "message"), [ diff --git a/tests/components/minecraft_server/const.py b/tests/components/minecraft_server/const.py index 0fb8e994133017..4447a6fe37e8dd 100644 --- a/tests/components/minecraft_server/const.py +++ b/tests/components/minecraft_server/const.py @@ -5,16 +5,13 @@ BedrockStatusPlayers, BedrockStatusResponse, BedrockStatusVersion, + JavaStatusPlayer, JavaStatusPlayers, JavaStatusResponse, JavaStatusVersion, LegacyStatusPlayers, LegacyStatusResponse, LegacyStatusVersion, - RawJavaResponse, - RawJavaResponsePlayer, - RawJavaResponsePlayers, - RawJavaResponseVersion, ) from homeassistant.components.minecraft_server.api import MinecraftServerData @@ -24,26 +21,19 @@ TEST_PORT = 25566 TEST_ADDRESS = f"{TEST_HOST}:{TEST_PORT}" -TEST_JAVA_STATUS_RESPONSE_RAW = RawJavaResponse( - description="Dummy MOTD", - players=RawJavaResponsePlayers( +TEST_JAVA_STATUS_RESPONSE = JavaStatusResponse( + raw={"foo": "bar"}, + players=JavaStatusPlayers( online=3, max=10, sample=[ - RawJavaResponsePlayer(id="1", name="Player 1"), - RawJavaResponsePlayer(id="2", name="Player 2"), - RawJavaResponsePlayer(id="3", name="Player 3"), + JavaStatusPlayer(id="1", name="Player 1"), + JavaStatusPlayer(id="2", name="Player 2"), + JavaStatusPlayer(id="3", name="Player 3"), ], ), - version=RawJavaResponseVersion(name="Dummy Version", protocol=123), - favicon="Dummy Icon", -) - -TEST_JAVA_STATUS_RESPONSE = JavaStatusResponse( - raw=TEST_JAVA_STATUS_RESPONSE_RAW, - players=JavaStatusPlayers.build(TEST_JAVA_STATUS_RESPONSE_RAW["players"]), - version=JavaStatusVersion.build(TEST_JAVA_STATUS_RESPONSE_RAW["version"]), - motd=Motd.parse(TEST_JAVA_STATUS_RESPONSE_RAW["description"], bedrock=False), + version=JavaStatusVersion(name="Dummy Version", protocol=123), + motd=Motd.parse("Dummy MOTD", bedrock=False), icon=None, enforces_secure_chat=False, latency=5, diff --git a/tests/components/mta/__init__.py b/tests/components/mta/__init__.py new file mode 100644 index 00000000000000..70fa60764d0487 --- /dev/null +++ b/tests/components/mta/__init__.py @@ -0,0 +1 @@ +"""Tests for the MTA New York City Transit integration.""" diff --git a/tests/components/mta/conftest.py b/tests/components/mta/conftest.py new file mode 100644 index 00000000000000..fdbd91b4611517 --- /dev/null +++ b/tests/components/mta/conftest.py @@ -0,0 +1,92 @@ +"""Test helpers for MTA tests.""" + +from collections.abc import Generator +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, patch + +from pymta import Arrival +import pytest + +from homeassistant.components.mta.const import CONF_LINE, CONF_STOP_ID, CONF_STOP_NAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain="mta", + data={ + CONF_LINE: "1", + CONF_STOP_ID: "127N", + CONF_STOP_NAME: "Times Sq - 42 St (N direction)", + }, + unique_id="1_127N", + entry_id="01J0000000000000000000000", + title="1 Line - Times Sq - 42 St (N direction)", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.mta.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_subway_feed() -> Generator[MagicMock]: + """Create a mock SubwayFeed for both coordinator and config flow.""" + # Fixed arrival times: 5, 10, and 15 minutes after test frozen time (2023-10-21 00:00:00 UTC) + mock_arrivals = [ + Arrival( + arrival_time=datetime(2023, 10, 21, 0, 5, 0, tzinfo=UTC), + route_id="1", + stop_id="127N", + destination="Van Cortlandt Park - 242 St", + ), + Arrival( + arrival_time=datetime(2023, 10, 21, 0, 10, 0, tzinfo=UTC), + route_id="1", + stop_id="127N", + destination="Van Cortlandt Park - 242 St", + ), + Arrival( + arrival_time=datetime(2023, 10, 21, 0, 15, 0, tzinfo=UTC), + route_id="1", + stop_id="127N", + destination="Van Cortlandt Park - 242 St", + ), + ] + + mock_stops = [ + { + "stop_id": "127N", + "stop_name": "Times Sq - 42 St", + "stop_sequence": 1, + }, + { + "stop_id": "127S", + "stop_name": "Times Sq - 42 St", + "stop_sequence": 2, + }, + ] + + with ( + patch( + "homeassistant.components.mta.coordinator.SubwayFeed", autospec=True + ) as mock_feed, + patch( + "homeassistant.components.mta.config_flow.SubwayFeed", + new=mock_feed, + ), + ): + mock_instance = mock_feed.return_value + mock_feed.get_feed_id_for_route.return_value = "1" + mock_instance.get_arrivals.return_value = mock_arrivals + mock_instance.get_stops.return_value = mock_stops + + yield mock_feed diff --git a/tests/components/mta/snapshots/test_sensor.ambr b/tests/components/mta/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..8d75b80ca2d878 --- /dev/null +++ b/tests/components/mta/snapshots/test_sensor.ambr @@ -0,0 +1,445 @@ +# serializer version: 1 +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival-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.1_line_times_sq_42_st_n_direction_127n_next_arrival', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Next arrival', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next arrival', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'next_arrival', + 'unique_id': '1_127N-next_arrival', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Next arrival', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:05:00+00:00', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_destination-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.1_line_times_sq_42_st_n_direction_127n_next_arrival_destination', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Next arrival destination', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Next arrival destination', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'next_arrival_destination', + 'unique_id': '1_127N-next_arrival_destination', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_destination-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Next arrival destination', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_destination', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Van Cortlandt Park - 242 St', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_route-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.1_line_times_sq_42_st_n_direction_127n_next_arrival_route', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Next arrival route', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Next arrival route', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'next_arrival_route', + 'unique_id': '1_127N-next_arrival_route', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_route-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Next arrival route', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_route', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival-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.1_line_times_sq_42_st_n_direction_127n_second_arrival', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Second arrival', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Second arrival', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'second_arrival', + 'unique_id': '1_127N-second_arrival', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Second arrival', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:10:00+00:00', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_destination-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.1_line_times_sq_42_st_n_direction_127n_second_arrival_destination', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Second arrival destination', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Second arrival destination', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'second_arrival_destination', + 'unique_id': '1_127N-second_arrival_destination', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_destination-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Second arrival destination', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_destination', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Van Cortlandt Park - 242 St', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_route-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.1_line_times_sq_42_st_n_direction_127n_second_arrival_route', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Second arrival route', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Second arrival route', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'second_arrival_route', + 'unique_id': '1_127N-second_arrival_route', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_route-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Second arrival route', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_route', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival-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.1_line_times_sq_42_st_n_direction_127n_third_arrival', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Third arrival', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Third arrival', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'third_arrival', + 'unique_id': '1_127N-third_arrival', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Third arrival', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:15:00+00:00', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_destination-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.1_line_times_sq_42_st_n_direction_127n_third_arrival_destination', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Third arrival destination', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Third arrival destination', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'third_arrival_destination', + 'unique_id': '1_127N-third_arrival_destination', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_destination-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Third arrival destination', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_destination', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Van Cortlandt Park - 242 St', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_route-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.1_line_times_sq_42_st_n_direction_127n_third_arrival_route', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Third arrival route', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Third arrival route', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'third_arrival_route', + 'unique_id': '1_127N-third_arrival_route', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_route-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Third arrival route', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_route', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- diff --git a/tests/components/mta/test_config_flow.py b/tests/components/mta/test_config_flow.py new file mode 100644 index 00000000000000..048ef444cd3a8c --- /dev/null +++ b/tests/components/mta/test_config_flow.py @@ -0,0 +1,161 @@ +"""Test the MTA config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from pymta import MTAFeedError + +from homeassistant.components.mta.const import ( + CONF_LINE, + CONF_STOP_ID, + CONF_STOP_NAME, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form( + hass: HomeAssistant, + mock_subway_feed: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the complete config flow.""" + # Start 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" + assert result["errors"] == {} + + # Select line + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_LINE: "1"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "stop" + assert result["errors"] == {} + + # Select stop and complete + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STOP_ID: "127N"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "1 Line - Times Sq - 42 St (N direction)" + assert result["data"] == { + CONF_LINE: "1", + CONF_STOP_ID: "127N", + CONF_STOP_NAME: "Times Sq - 42 St (N direction)", + } + assert result["result"].unique_id == "1_127N" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_already_configured( + hass: HomeAssistant, + mock_subway_feed: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we handle already configured.""" + 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"], + {CONF_LINE: "1"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STOP_ID: "127N"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_form_connection_error( + hass: HomeAssistant, + mock_subway_feed: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we handle connection errors and can recover.""" + mock_instance = mock_subway_feed.return_value + mock_instance.get_arrivals.side_effect = MTAFeedError("Connection error") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_LINE: "1"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STOP_ID: "127S"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # Test recovery - reset mock to succeed + mock_instance.get_arrivals.side_effect = None + mock_instance.get_arrivals.return_value = [] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STOP_ID: "127S"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_get_stops( + hass: HomeAssistant, mock_subway_feed: MagicMock +) -> None: + """Test we abort when we cannot get stops.""" + mock_instance = mock_subway_feed.return_value + mock_instance.get_stops.side_effect = MTAFeedError("Feed error") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_LINE: "1"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_form_no_stops_found( + hass: HomeAssistant, mock_subway_feed: MagicMock +) -> None: + """Test we abort when no stops are found.""" + mock_instance = mock_subway_feed.return_value + mock_instance.get_stops.return_value = [] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_LINE: "1"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_stops" diff --git a/tests/components/mta/test_init.py b/tests/components/mta/test_init.py new file mode 100644 index 00000000000000..05751187ce7165 --- /dev/null +++ b/tests/components/mta/test_init.py @@ -0,0 +1,29 @@ +"""Test the MTA New York City Transit init.""" + +from unittest.mock import MagicMock + +from homeassistant.components.mta.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_subway_feed: MagicMock, +) -> None: + """Test setting up and unloading an entry.""" + 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() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert DOMAIN in hass.config_entries.async_domains() + + 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 diff --git a/tests/components/mta/test_sensor.py b/tests/components/mta/test_sensor.py new file mode 100644 index 00000000000000..29d59dd67d7811 --- /dev/null +++ b/tests/components/mta/test_sensor.py @@ -0,0 +1,30 @@ +"""Test the MTA sensor platform.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.freeze_time("2023-10-21") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_subway_feed: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the sensor entity.""" + await hass.config.async_set_time_zone("UTC") + + mock_config_entry.add_to_hass(hass) + 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) diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index 394d1f22c56a25..2f417aba9131e8 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -149,7 +149,21 @@ async def auth( def cleanup_media_storage(hass: HomeAssistant) -> Generator[str]: """Test cleanup, remove any media storage persisted during the test.""" tmp_path = str(uuid.uuid4()) - with patch("homeassistant.components.nest.media_source.MEDIA_PATH", new=tmp_path): + with patch( + "homeassistant.components.nest.media_source.MEDIA_CACHE_PATH", new=tmp_path + ): + full_path = hass.config.cache_path(DOMAIN, tmp_path) + yield full_path + shutil.rmtree(full_path, ignore_errors=True) + + +@pytest.fixture(name="legacy_media_path") +def cleanup_legacy_media_storage(hass: HomeAssistant) -> Generator[str]: + """Test cleanup, remove any media storage persisted during the test.""" + tmp_path = str(uuid.uuid4()) + with patch( + "homeassistant.components.nest.media_source.LEGACY_MEDIA_PATH", new=tmp_path + ): full_path = hass.config.path(tmp_path) yield full_path shutil.rmtree(full_path, ignore_errors=True) diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 0b0654fc69c2dc..50275050b4d80c 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -1653,3 +1653,76 @@ async def test_remove_stale_media( assert not extra_media1.exists() assert not extra_media2.exists() assert extra_media3.exists() + + +async def test_media_migration( + hass: HomeAssistant, + setup_platform, + legacy_media_path: str, + media_path: str, +) -> None: + """Test migration of media files from legacy path to new path.""" + legacy_path = pathlib.Path(legacy_media_path) + cache_path = pathlib.Path(media_path) + + # Create some dummy files in the legacy path + device_id = "device-1" + legacy_device_path = legacy_path / device_id + legacy_device_path.mkdir(parents=True) + + file1 = legacy_device_path / "event1.jpg" + file1.write_text("content1") + + file2 = legacy_device_path / "event2.mp4" + file2.write_text("content2") + + # Run setup (which triggers migration) + await setup_platform() + + # Check if files are moved to cache path + cache_device_path = cache_path / device_id + assert (cache_device_path / "event1.jpg").exists() + assert (cache_device_path / "event1.jpg").read_text() == "content1" + assert (cache_device_path / "event2.mp4").exists() + assert (cache_device_path / "event2.mp4").read_text() == "content2" + + # Check if files are removed from legacy path + assert not file1.exists() + assert not file2.exists() + assert not legacy_device_path.exists() + assert not legacy_path.exists() + + +async def test_media_migration_failure( + hass: HomeAssistant, + setup_platform, + legacy_media_path: str, + media_path: str, +) -> None: + """Test migration failure handles the error gracefully.""" + legacy_path = pathlib.Path(legacy_media_path) + cache_path = pathlib.Path(media_path) + + # Create some dummy files in the legacy path + device_id = "device-1" + legacy_device_path = legacy_path / device_id + legacy_device_path.mkdir(parents=True) + file1 = legacy_device_path / "event1.jpg" + file1.write_text("content1") + + # Mock shutil.move to fail + with patch( + "homeassistant.components.nest.media_source.shutil.move", + side_effect=OSError("Storage full"), + ): + # Run setup (which triggers migration) + # Note: setup_platform handles the integration setup which calls async_get_media_event_store + await setup_platform() + + # Verify that the legacy path still exists (migration was abandoned) + assert file1.exists() + assert legacy_path.exists() + + # Verify that the cache path was still created (it should be empty) + assert cache_path.exists() + assert not (cache_path / device_id).exists() diff --git a/tests/components/nibe_heatpump/test_button.py b/tests/components/nibe_heatpump/test_button.py index 4f2bab7ad0a511..d13e9511d44722 100644 --- a/tests/components/nibe_heatpump/test_button.py +++ b/tests/components/nibe_heatpump/test_button.py @@ -8,7 +8,7 @@ from nibe.heatpump import Model import pytest -from homeassistant.components.button import DOMAIN as PLATFORM_DOMAIN, SERVICE_PRESS +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ( ATTR_ENTITY_ID, STATE_UNAVAILABLE, @@ -67,7 +67,7 @@ async def test_reset_button( # Press button await hass.services.async_call( - PLATFORM_DOMAIN, + BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: entity_id}, blocking=True, diff --git a/tests/components/nibe_heatpump/test_climate.py b/tests/components/nibe_heatpump/test_climate.py index 039113892c1022..b64bc9036d938a 100644 --- a/tests/components/nibe_heatpump/test_climate.py +++ b/tests/components/nibe_heatpump/test_climate.py @@ -19,7 +19,7 @@ ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, ATTR_TEMPERATURE, - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, SERVICE_SET_TEMPERATURE, HVACMode, @@ -164,7 +164,7 @@ async def test_set_temperature_supported_cooling( ) await hass.services.async_call( - PLATFORM_DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: entity_id, @@ -181,7 +181,7 @@ async def test_set_temperature_supported_cooling( mock_connection.write_coil.reset_mock() await hass.services.async_call( - PLATFORM_DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: entity_id, @@ -199,7 +199,7 @@ async def test_set_temperature_supported_cooling( with pytest.raises(ServiceValidationError): await hass.services.async_call( - PLATFORM_DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: entity_id, @@ -209,7 +209,7 @@ async def test_set_temperature_supported_cooling( ) await hass.services.async_call( - PLATFORM_DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: entity_id, @@ -255,7 +255,7 @@ async def test_set_temperature_unsupported_cooling( # Set temperature to heat await hass.services.async_call( - PLATFORM_DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: entity_id, @@ -272,7 +272,7 @@ async def test_set_temperature_unsupported_cooling( # Attempt to set temperature to cool should raise ServiceValidationError with pytest.raises(ServiceValidationError): await hass.services.async_call( - PLATFORM_DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: entity_id, @@ -324,7 +324,7 @@ async def test_set_hvac_mode( ) await hass.services.async_call( - PLATFORM_DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { ATTR_ENTITY_ID: entity_id, @@ -364,7 +364,7 @@ async def test_set_invalid_hvac_mode( await async_add_model(hass, model) with pytest.raises(ServiceValidationError): await hass.services.async_call( - PLATFORM_DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { ATTR_ENTITY_ID: entity_id, diff --git a/tests/components/nibe_heatpump/test_number.py b/tests/components/nibe_heatpump/test_number.py index 6e004a0554ef25..2881ac62a33192 100644 --- a/tests/components/nibe_heatpump/test_number.py +++ b/tests/components/nibe_heatpump/test_number.py @@ -11,7 +11,7 @@ from homeassistant.components.number import ( ATTR_VALUE, - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) from homeassistant.const import ATTR_ENTITY_ID, Platform @@ -95,7 +95,7 @@ async def test_set_value( # Write value await hass.services.async_call( - PLATFORM_DOMAIN, + NUMBER_DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, blocking=True, @@ -158,7 +158,7 @@ async def test_set_value_fail( # Write value with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( - PLATFORM_DOMAIN, + NUMBER_DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, blocking=True, @@ -192,7 +192,7 @@ async def test_set_value_same( # Write value await hass.services.async_call( - PLATFORM_DOMAIN, + NUMBER_DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, blocking=True, diff --git a/tests/components/onedrive_for_business/conftest.py b/tests/components/onedrive_for_business/conftest.py index 9ad609c8cc41a3..0f30419688a2b2 100644 --- a/tests/components/onedrive_for_business/conftest.py +++ b/tests/components/onedrive_for_business/conftest.py @@ -7,7 +7,6 @@ from onedrive_personal_sdk.const import DriveState, DriveType from onedrive_personal_sdk.models.items import ( - AppRoot, Drive, DriveQuota, File, @@ -99,27 +98,6 @@ def mock_onedrive_client_init() -> Generator[MagicMock]: yield onedrive_client -@pytest.fixture -def mock_approot() -> AppRoot: - """Return a mocked approot.""" - return AppRoot( - id="id", - child_count=0, - size=0, - name="name", - parent_reference=ItemParentReference( - drive_id="mock_drive_id", id="id", path="path" - ), - created_by=IdentitySet( - user=User( - display_name="John Doe", - id="id", - email="john@doe.com", - ) - ), - ) - - @pytest.fixture def mock_drive() -> Drive: """Return a mocked drive.""" @@ -199,7 +177,6 @@ def mock_metadata_file() -> File: @pytest.fixture(autouse=True) def mock_onedrive_client( mock_onedrive_client_init: MagicMock, - mock_approot: AppRoot, mock_drive: Drive, mock_folder: Folder, mock_backup_file: File, @@ -207,7 +184,6 @@ def mock_onedrive_client( ) -> Generator[MagicMock]: """Return a mocked GraphServiceClient.""" client = mock_onedrive_client_init.return_value - client.get_approot.return_value = mock_approot client.create_folder.return_value = mock_folder client.list_drive_items.return_value = [mock_backup_file, mock_metadata_file] client.get_drive_item.return_value = mock_folder diff --git a/tests/components/onedrive_for_business/test_config_flow.py b/tests/components/onedrive_for_business/test_config_flow.py index ce42892a67931d..1c470e1f4a772f 100644 --- a/tests/components/onedrive_for_business/test_config_flow.py +++ b/tests/components/onedrive_for_business/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock from onedrive_personal_sdk.exceptions import OneDriveException -from onedrive_personal_sdk.models.items import Drive +from onedrive_personal_sdk.models.items import Drive, IdentitySet import pytest from homeassistant import config_entries @@ -104,7 +104,7 @@ async def test_full_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 - assert result["title"] == "John Doe's OneDrive" + assert result["title"] == "John Doe's OneDrive (john@doe.com)" assert result["result"].unique_id == "mock_drive_id" assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" @@ -119,11 +119,11 @@ async def test_full_flow_with_owner_not_found( aioclient_mock: AiohttpClientMocker, mock_setup_entry: AsyncMock, mock_onedrive_client: MagicMock, - mock_approot: MagicMock, + mock_drive: Drive, ) -> None: """Ensure we get a default title if the drive's owner can't be read.""" - mock_approot.created_by.user = None + mock_drive.owner = IdentitySet(user=None) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -194,7 +194,7 @@ async def test_error_during_folder_creation( result["flow_id"], {CONF_FOLDER_PATH: "myFolder"} ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "John Doe's OneDrive" + assert result["title"] == "John Doe's OneDrive (john@doe.com)" assert result["result"].unique_id == "mock_drive_id" assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" @@ -220,7 +220,7 @@ async def test_flow_errors( ) -> None: """Test errors during flow.""" - mock_onedrive_client.get_approot.side_effect = exception + mock_onedrive_client.get_drive.side_effect = exception result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} diff --git a/tests/components/onedrive_for_business/test_init.py b/tests/components/onedrive_for_business/test_init.py index 5f80ef4f1320e2..613f023e4c929e 100644 --- a/tests/components/onedrive_for_business/test_init.py +++ b/tests/components/onedrive_for_business/test_init.py @@ -8,7 +8,7 @@ NotFoundError, OneDriveException, ) -from onedrive_personal_sdk.models.items import AppRoot, Folder +from onedrive_personal_sdk.models.items import Folder import pytest from homeassistant.components.onedrive_for_business.const import ( @@ -72,7 +72,6 @@ async def test_get_integration_folder_creation( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_onedrive_client: MagicMock, - mock_approot: AppRoot, mock_folder: Folder, ) -> None: """Test faulty integration folder creation.""" diff --git a/tests/components/openhome/test_update.py b/tests/components/openhome/test_update.py index 354ed26af64464..15dadc27579034 100644 --- a/tests/components/openhome/test_update.py +++ b/tests/components/openhome/test_update.py @@ -10,7 +10,7 @@ ATTR_LATEST_VERSION, ATTR_RELEASE_SUMMARY, ATTR_RELEASE_URL, - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL, UpdateDeviceClass, ) @@ -148,7 +148,7 @@ async def test_update_available(hass: HomeAssistant) -> None: ) await hass.services.async_call( - PLATFORM_DOMAIN, + UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: "update.friendly_name"}, blocking=True, @@ -166,7 +166,7 @@ async def test_firmware_update_not_required(hass: HomeAssistant) -> None: with pytest.raises(HomeAssistantError): await hass.services.async_call( - PLATFORM_DOMAIN, + UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: "update.friendly_name"}, blocking=True, diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index 6df5681d9e1cd1..a45bff9b212129 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -10,6 +10,9 @@ import python_otbr_api from homeassistant.components import otbr +from homeassistant.components.homeassistant_hardware import ( + DOMAIN as HOMEASSISTANT_HARDWARE_DOMAIN, +) from homeassistant.components.homeassistant_hardware.helpers import ( async_register_firmware_info_callback, ) @@ -1010,7 +1013,7 @@ async def test_hassio_discovery_reload( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_addon_info ) -> None: """Test the hassio discovery flow.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, HOMEASSISTANT_HARDWARE_DOMAIN, {}) aioclient_mock.get( "http://core-openthread-border-router:8081/node/dataset/active", text="" diff --git a/tests/components/otbr/test_homeassistant_hardware.py b/tests/components/otbr/test_homeassistant_hardware.py index 7f831656d06e0d..606c0a008e43ec 100644 --- a/tests/components/otbr/test_homeassistant_hardware.py +++ b/tests/components/otbr/test_homeassistant_hardware.py @@ -4,6 +4,9 @@ import pytest +from homeassistant.components.homeassistant_hardware import ( + DOMAIN as HOMEASSISTANT_HARDWARE_DOMAIN, +) from homeassistant.components.homeassistant_hardware.helpers import ( async_register_firmware_info_callback, ) @@ -174,7 +177,7 @@ async def test_hardware_firmware_info_provider_notification( ) otbr.add_to_hass(hass) - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, HOMEASSISTANT_HARDWARE_DOMAIN, {}) callback = Mock() async_register_firmware_info_callback(hass, DEVICE_PATH, callback) diff --git a/tests/components/sleepiq/snapshots/test_sensor.ambr b/tests/components/sleepiq/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..2bf892e2277bfe --- /dev/null +++ b/tests/components/sleepiq/snapshots/test_sensor.ambr @@ -0,0 +1,213 @@ +# serializer version: 1 +# name: test_sensors[sensor.sleepnumber_test_bed_sleeper_r_pressure-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.sleepnumber_test_bed_sleeper_r_pressure', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'SleepNumber Test Bed Sleeper R Pressure', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:bed', + 'original_name': 'SleepNumber Test Bed Sleeper R Pressure', + 'platform': 'sleepiq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '43219_pressure', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.sleepnumber_test_bed_sleeper_r_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SleepNumber Test Bed Sleeper R Pressure', + 'icon': 'mdi:bed', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.sleepnumber_test_bed_sleeper_r_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1400', + }) +# --- +# name: test_sensors[sensor.sleepnumber_test_bed_sleeper_r_sleepnumber-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.sleepnumber_test_bed_sleeper_r_sleepnumber', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'SleepNumber Test Bed Sleeper R SleepNumber', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:bed', + 'original_name': 'SleepNumber Test Bed Sleeper R SleepNumber', + 'platform': 'sleepiq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '43219_sleep_number', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.sleepnumber_test_bed_sleeper_r_sleepnumber-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SleepNumber Test Bed Sleeper R SleepNumber', + 'icon': 'mdi:bed', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.sleepnumber_test_bed_sleeper_r_sleepnumber', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_sensors[sensor.sleepnumber_test_bed_sleeperl_pressure-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.sleepnumber_test_bed_sleeperl_pressure', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'SleepNumber Test Bed SleeperL Pressure', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:bed', + 'original_name': 'SleepNumber Test Bed SleeperL Pressure', + 'platform': 'sleepiq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '98765_pressure', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.sleepnumber_test_bed_sleeperl_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SleepNumber Test Bed SleeperL Pressure', + 'icon': 'mdi:bed', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.sleepnumber_test_bed_sleeperl_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000', + }) +# --- +# name: test_sensors[sensor.sleepnumber_test_bed_sleeperl_sleepnumber-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.sleepnumber_test_bed_sleeperl_sleepnumber', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'SleepNumber Test Bed SleeperL SleepNumber', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:bed', + 'original_name': 'SleepNumber Test Bed SleeperL SleepNumber', + 'platform': 'sleepiq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '98765_sleep_number', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.sleepnumber_test_bed_sleeperl_sleepnumber-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SleepNumber Test Bed SleeperL SleepNumber', + 'icon': 'mdi:bed', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.sleepnumber_test_bed_sleeperl_sleepnumber', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- diff --git a/tests/components/sleepiq/test_sensor.py b/tests/components/sleepiq/test_sensor.py index eb558850fb3002..f177ef6670b164 100644 --- a/tests/components/sleepiq/test_sensor.py +++ b/tests/components/sleepiq/test_sensor.py @@ -1,96 +1,23 @@ """The tests for SleepIQ sensor platform.""" +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_ICON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import ( - BED_NAME, - BED_NAME_LOWER, - SLEEPER_L_ID, - SLEEPER_L_NAME, - SLEEPER_L_NAME_LOWER, - SLEEPER_R_ID, - SLEEPER_R_NAME, - SLEEPER_R_NAME_LOWER, - setup_platform, -) - - -async def test_sleepnumber_sensors( - hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq -) -> None: - """Test the SleepIQ sleepnumber for a bed with two sides.""" - entry = await setup_platform(hass, SENSOR_DOMAIN) - - state = hass.states.get( - f"sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_sleepnumber" - ) - assert state.state == "40" - assert state.attributes.get(ATTR_ICON) == "mdi:bed" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} SleepNumber" - ) +from .conftest import setup_platform - entry = entity_registry.async_get( - f"sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_sleepnumber" - ) - assert entry - assert entry.unique_id == f"{SLEEPER_L_ID}_sleep_number" +from tests.common import snapshot_platform - state = hass.states.get( - f"sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_sleepnumber" - ) - assert state.state == "80" - assert state.attributes.get(ATTR_ICON) == "mdi:bed" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == f"SleepNumber {BED_NAME} {SLEEPER_R_NAME} SleepNumber" - ) - entry = entity_registry.async_get( - f"sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_sleepnumber" - ) - assert entry - assert entry.unique_id == f"{SLEEPER_R_ID}_sleep_number" - - -async def test_pressure_sensors( - hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_asyncsleepiq, + snapshot: SnapshotAssertion, ) -> None: - """Test the SleepIQ pressure for a bed with two sides.""" + """Test the SleepIQ sleepnumber for a bed with two sides.""" entry = await setup_platform(hass, SENSOR_DOMAIN) - state = hass.states.get( - f"sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_pressure" - ) - assert state.state == "1000" - assert state.attributes.get(ATTR_ICON) == "mdi:bed" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Pressure" - ) - - entry = entity_registry.async_get( - f"sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_pressure" - ) - assert entry - assert entry.unique_id == f"{SLEEPER_L_ID}_pressure" - - state = hass.states.get( - f"sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_pressure" - ) - assert state.state == "1400" - assert state.attributes.get(ATTR_ICON) == "mdi:bed" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == f"SleepNumber {BED_NAME} {SLEEPER_R_NAME} Pressure" - ) - - entry = entity_registry.async_get( - f"sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_pressure" - ) - assert entry - assert entry.unique_id == f"{SLEEPER_R_ID}_pressure" + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py index 6949ccb3c97f4d..acd9cfe197e394 100644 --- a/tests/components/smlight/test_update.py +++ b/tests/components/smlight/test_update.py @@ -16,7 +16,7 @@ ATTR_INSTALLED_VERSION, ATTR_LATEST_VERSION, ATTR_UPDATE_PERCENTAGE, - DOMAIN as PLATFORM, + DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL, ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform @@ -113,7 +113,7 @@ async def test_update_firmware( assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" await hass.services.async_call( - PLATFORM, + UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: entity_id}, blocking=False, @@ -167,7 +167,7 @@ async def test_update_zigbee2_firmware( assert state.attributes[ATTR_LATEST_VERSION] == "20240716" await hass.services.async_call( - PLATFORM, + UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: entity_id}, blocking=False, @@ -212,7 +212,7 @@ async def test_update_legacy_firmware_v2( assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" await hass.services.async_call( - PLATFORM, + UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: entity_id}, blocking=False, @@ -253,7 +253,7 @@ async def test_update_firmware_failed( assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" await hass.services.async_call( - PLATFORM, + UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: entity_id}, blocking=False, @@ -300,7 +300,7 @@ async def test_update_reboot_timeout( ), ): await hass.services.async_call( - PLATFORM, + UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: entity_id}, blocking=False, diff --git a/tests/components/tplink_omada/conftest.py b/tests/components/tplink_omada/conftest.py index 07eb636ccad2b4..f6d840e5650394 100644 --- a/tests/components/tplink_omada/conftest.py +++ b/tests/components/tplink_omada/conftest.py @@ -2,7 +2,6 @@ from collections.abc import AsyncGenerator, Generator from functools import partial -import json from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -14,6 +13,7 @@ OmadaWirelessClient, ) from tplink_omada_client.devices import ( + OmadaFirmwareUpdate, OmadaGateway, OmadaListDevice, OmadaSwitch, @@ -25,7 +25,11 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_load_fixture +from tests.common import ( + MockConfigEntry, + async_load_json_array_fixture, + async_load_json_object_fixture, +) @pytest.fixture @@ -59,29 +63,44 @@ async def mock_omada_site_client(hass: HomeAssistant) -> AsyncGenerator[AsyncMoc """Mock Omada site client.""" site_client = MagicMock() - gateway_data = json.loads( - await async_load_fixture(hass, "gateway-TL-ER7212PC.json", DOMAIN) + gateway_data = await async_load_json_object_fixture( + hass, "gateway-TL-ER7212PC.json", DOMAIN ) gateway = OmadaGateway(gateway_data) site_client.get_gateway = AsyncMock(return_value=gateway) - switch1_data = json.loads( - await async_load_fixture(hass, "switch-TL-SG3210XHP-M2.json", DOMAIN) + switch1_data = await async_load_json_object_fixture( + hass, "switch-TL-SG3210XHP-M2.json", DOMAIN ) switch1 = OmadaSwitch(switch1_data) site_client.get_switches = AsyncMock(return_value=[switch1]) site_client.get_switch = AsyncMock(return_value=switch1) - devices_data = json.loads(await async_load_fixture(hass, "devices.json", DOMAIN)) + devices_data = await async_load_json_array_fixture(hass, "devices.json", DOMAIN) devices = [OmadaListDevice(d) for d in devices_data] site_client.get_devices = AsyncMock(return_value=devices) - switch1_ports_data = json.loads( - await async_load_fixture(hass, "switch-ports-TL-SG3210XHP-M2.json", DOMAIN) + switch1_ports_data = await async_load_json_array_fixture( + hass, "switch-ports-TL-SG3210XHP-M2.json", DOMAIN ) switch1_ports = [OmadaSwitchPortDetails(p) for p in switch1_ports_data] site_client.get_switch_ports = AsyncMock(return_value=switch1_ports) + # Mock firmware update API + async def get_firmware_details( + device: OmadaListDevice, + ) -> OmadaFirmwareUpdate | None: + """Mock getting firmware details for a device.""" + if device.need_upgrade: + firmware_data = await async_load_json_object_fixture( + hass, f"firmware-update-{device.mac}.json", DOMAIN + ) + return OmadaFirmwareUpdate(firmware_data) + return None + + site_client.get_firmware_details = AsyncMock(side_effect=get_firmware_details) + site_client.start_firmware_upgrade = AsyncMock() + async def async_empty() -> AsyncGenerator: for c in (): yield c @@ -114,8 +133,8 @@ async def _get_mock_known_clients( hass: HomeAssistant, ) -> AsyncGenerator[OmadaNetworkClient]: """Mock known clients of the Omada network.""" - known_clients_data = json.loads( - await async_load_fixture(hass, "known-clients.json", DOMAIN) + known_clients_data = await async_load_json_array_fixture( + hass, "known-clients.json", DOMAIN ) for c in known_clients_data: if c["wireless"]: @@ -128,8 +147,8 @@ async def _get_mock_connected_clients( hass: HomeAssistant, ) -> AsyncGenerator[OmadaConnectedClient]: """Mock connected clients of the Omada network.""" - connected_clients_data = json.loads( - await async_load_fixture(hass, "connected-clients.json", DOMAIN) + connected_clients_data = await async_load_json_array_fixture( + hass, "connected-clients.json", DOMAIN ) for c in connected_clients_data: if c["wireless"]: @@ -140,8 +159,8 @@ async def _get_mock_connected_clients( async def _get_mock_client(hass: HomeAssistant, mac: str) -> OmadaNetworkClient: """Mock an Omada client.""" - connected_clients_data = json.loads( - await async_load_fixture(hass, "connected-clients.json", DOMAIN) + connected_clients_data = await async_load_json_array_fixture( + hass, "connected-clients.json", DOMAIN ) for c in connected_clients_data: diff --git a/tests/components/tplink_omada/fixtures/devices.json b/tests/components/tplink_omada/fixtures/devices.json index d92fd5f7d663d1..16cda0612d2784 100644 --- a/tests/components/tplink_omada/fixtures/devices.json +++ b/tests/components/tplink_omada/fixtures/devices.json @@ -35,7 +35,7 @@ "memUtil": 20, "status": 14, "statusCategory": 1, - "needUpgrade": false, + "needUpgrade": true, "fwDownload": false } ] diff --git a/tests/components/tplink_omada/fixtures/firmware-update-54-AF-97-00-00-01.json b/tests/components/tplink_omada/fixtures/firmware-update-54-AF-97-00-00-01.json new file mode 100644 index 00000000000000..1d3147139768eb --- /dev/null +++ b/tests/components/tplink_omada/fixtures/firmware-update-54-AF-97-00-00-01.json @@ -0,0 +1,5 @@ +{ + "curFwVer": "1.0.12 Build 20230203 Rel.36545", + "lastFwVer": "1.0.15 Build 20231101 Rel.40123", + "fwReleaseLog": "Bug fixes and performance improvements" +} diff --git a/tests/components/tplink_omada/snapshots/test_update.ambr b/tests/components/tplink_omada/snapshots/test_update.ambr new file mode 100644 index 00000000000000..ce856b4adf5ee9 --- /dev/null +++ b/tests/components/tplink_omada/snapshots/test_update.ambr @@ -0,0 +1,125 @@ +# serializer version: 1 +# name: test_entities[update.test_poe_switch_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_poe_switch_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Firmware', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'tplink_omada', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '54-AF-97-00-00-01_firmware', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[update.test_poe_switch_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/tplink_omada/icon.png', + 'friendly_name': 'Test PoE Switch Firmware', + 'in_progress': False, + 'installed_version': '1.0.12 Build 20230203 Rel.36545', + 'latest_version': '1.0.15 Build 20231101 Rel.40123', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_poe_switch_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[update.test_router_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_router_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Firmware', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'tplink_omada', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'AA-BB-CC-DD-EE-FF_firmware', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[update.test_router_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/tplink_omada/icon.png', + 'friendly_name': 'Test Router Firmware', + 'in_progress': False, + 'installed_version': '1.1.1 Build 20230901 Rel.55651', + 'latest_version': '1.1.1 Build 20230901 Rel.55651', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_router_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tplink_omada/test_binary_sensor.py b/tests/components/tplink_omada/test_binary_sensor.py index 3b258f172b9262..9f4929fb123a2d 100644 --- a/tests/components/tplink_omada/test_binary_sensor.py +++ b/tests/components/tplink_omada/test_binary_sensor.py @@ -18,7 +18,7 @@ Generator, MockConfigEntry, async_fire_time_changed, - load_json_array_fixture, + async_load_json_array_fixture, snapshot_platform, ) @@ -51,7 +51,7 @@ async def test_no_gateway_creates_no_port_sensors( ) -> None: """Test that if there is no gateway, no gateway port sensors are created.""" - _remove_test_device(mock_omada_site_client, 0) + await _remove_test_device(hass, mock_omada_site_client, 0) mock_config_entry.add_to_hass(hass) @@ -70,7 +70,8 @@ async def test_disconnected_device_sensor_not_registered( ) -> None: """Test that if the gateway is not connected to the controller, gateway entities are not created.""" - _set_test_device_status( + await _set_test_device_status( + hass, mock_omada_site_client, 0, DeviceStatus.DISCONNECTED.value, @@ -87,7 +88,8 @@ async def test_disconnected_device_sensor_not_registered( assert entity is None # "Connect" the gateway - _set_test_device_status( + await _set_test_device_status( + hass, mock_omada_site_client, 0, DeviceStatus.CONNECTED.value, @@ -105,13 +107,14 @@ async def test_disconnected_device_sensor_not_registered( mock_omada_site_client.get_gateway.assert_called_once_with("AA-BB-CC-DD-EE-FF") -def _set_test_device_status( +async def _set_test_device_status( + hass: HomeAssistant, mock_omada_site_client: MagicMock, dev_index: int, status: int, status_category: int, ) -> None: - devices_data = load_json_array_fixture("devices.json", DOMAIN) + devices_data = await async_load_json_array_fixture(hass, "devices.json", DOMAIN) devices_data[dev_index]["status"] = status devices_data[dev_index]["statusCategory"] = status_category devices = [OmadaListDevice(d) for d in devices_data] @@ -120,11 +123,12 @@ def _set_test_device_status( mock_omada_site_client.get_devices.return_value = devices -def _remove_test_device( +async def _remove_test_device( + hass: HomeAssistant, mock_omada_site_client: MagicMock, dev_index: int, ) -> None: - devices_data = load_json_array_fixture("devices.json", DOMAIN) + devices_data = await async_load_json_array_fixture(hass, "devices.json", DOMAIN) del devices_data[dev_index] devices = [OmadaListDevice(d) for d in devices_data] diff --git a/tests/components/tplink_omada/test_sensor.py b/tests/components/tplink_omada/test_sensor.py index 9fcea14129c08c..fcae399fbdfd04 100644 --- a/tests/components/tplink_omada/test_sensor.py +++ b/tests/components/tplink_omada/test_sensor.py @@ -1,7 +1,6 @@ """Tests for TP-Link Omada sensor entities.""" from datetime import timedelta -import json from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory @@ -18,7 +17,7 @@ from tests.common import ( MockConfigEntry, async_fire_time_changed, - load_fixture, + async_load_json_array_fixture, snapshot_platform, ) @@ -63,7 +62,8 @@ async def test_device_specific_status( assert entity is not None assert entity.state == "connected" - _set_test_device_status( + await _set_test_device_status( + hass, mock_omada_site_client, DeviceStatus.ADOPT_FAILED.value, DeviceStatusCategory.CONNECTED.value, @@ -89,9 +89,10 @@ async def test_device_category_status( assert entity is not None assert entity.state == "connected" - _set_test_device_status( + await _set_test_device_status( + hass, mock_omada_site_client, - DeviceStatus.PENDING_WIRELESS, + DeviceStatus.PENDING_WIRELESS.value, DeviceStatusCategory.PENDING.value, ) @@ -103,12 +104,13 @@ async def test_device_category_status( assert entity and entity.state == "pending" -def _set_test_device_status( +async def _set_test_device_status( + hass: HomeAssistant, mock_omada_site_client: MagicMock, status: int, status_category: int, ) -> None: - devices_data = json.loads(load_fixture("devices.json", DOMAIN)) + devices_data = await async_load_json_array_fixture(hass, "devices.json", DOMAIN) devices_data[1]["status"] = status devices_data[1]["statusCategory"] = status_category devices = [OmadaListDevice(d) for d in devices_data] diff --git a/tests/components/tplink_omada/test_update.py b/tests/components/tplink_omada/test_update.py new file mode 100644 index 00000000000000..af6280edfae898 --- /dev/null +++ b/tests/components/tplink_omada/test_update.py @@ -0,0 +1,204 @@ +"""Tests for TP-Link Omada update entities.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion +from tplink_omada_client.devices import OmadaListDevice +from tplink_omada_client.exceptions import OmadaClientException, RequestFailed + +from homeassistant.components.tplink_omada.coordinator import POLL_DEVICES +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_json_array_fixture, + snapshot_platform, +) +from tests.typing import WebSocketGenerator + +POLL_INTERVAL = timedelta(seconds=POLL_DEVICES) + + +async def _rebuild_device_list_with_update( + hass: HomeAssistant, mac: str, **overrides +) -> list[OmadaListDevice]: + """Rebuild device list from fixture with specified overrides for a device.""" + devices_data = await async_load_json_array_fixture( + hass, "devices.json", "tplink_omada" + ) + + for device_data in devices_data: + if device_data["mac"] == mac: + device_data.update(overrides) + + return [OmadaListDevice(d) for d in devices_data] + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_omada_client: MagicMock, +) -> MockConfigEntry: + """Set up the TP-Link Omada integration for testing.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.tplink_omada.PLATFORMS", [Platform.UPDATE]): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry + + +async def test_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the creation of the TP-Link Omada update entities.""" + await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id) + + +async def test_firmware_download_in_progress( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_omada_site_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test update entity when firmware download is in progress.""" + entity_id = "update.test_poe_switch_firmware" + + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Rebuild device list with fwDownload set to True for the switch + updated_devices = await _rebuild_device_list_with_update( + hass, "54-AF-97-00-00-01", fwDownload=True + ) + mock_omada_site_client.get_devices.return_value = updated_devices + + # Trigger coordinator update + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Verify update entity shows in progress + entity = hass.states.get(entity_id) + assert entity is not None + assert entity.attributes.get(ATTR_IN_PROGRESS) is True + + +async def test_install_firmware_success( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_omada_site_client: MagicMock, +) -> None: + """Test successful firmware installation.""" + entity_id = "update.test_poe_switch_firmware" + + # Verify update is available + entity = hass.states.get(entity_id) + assert entity is not None + assert entity.state == STATE_ON + + # Call install service + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + # Verify start_firmware_upgrade was called with the correct device + mock_omada_site_client.start_firmware_upgrade.assert_awaited_once() + await_args = mock_omada_site_client.start_firmware_upgrade.await_args[0] + assert await_args[0].mac == "54-AF-97-00-00-01" + + +@pytest.mark.parametrize( + ("exception_type", "error_message"), + [ + ( + RequestFailed(500, "Update rejected"), + "Firmware update request rejected", + ), + ( + OmadaClientException("Connection error"), + "Unable to send Firmware update request. Check the controller is online.", + ), + ], +) +async def test_install_firmware_exceptions( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_omada_site_client: MagicMock, + exception_type: Exception, + error_message: str, +) -> None: + """Test firmware installation exception handling.""" + entity_id = "update.test_poe_switch_firmware" + + # Mock exception + mock_omada_site_client.start_firmware_upgrade = AsyncMock( + side_effect=exception_type + ) + + # Call install service and expect error + with pytest.raises( + HomeAssistantError, + match=error_message, + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("entity_name", "expected_notes"), + [ + ("test_router", None), + ("test_poe_switch", "Bug fixes and performance improvements"), + ], +) +async def test_release_notes( + hass: HomeAssistant, + init_integration: MockConfigEntry, + hass_ws_client: WebSocketGenerator, + entity_name: str, + expected_notes: str | None, +) -> None: + """Test that release notes are available via websocket.""" + entity_id = f"update.{entity_name}_firmware" + + # Get release notes via websocket + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": entity_id, + } + ) + result = await client.receive_json() + + assert expected_notes == result["result"] diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index e0b716919af2b4..4aef840982f2ab 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -1114,6 +1114,127 @@ async def test_bandwidth_port_sensors( assert hass.states.get("sensor.mock_name_port_2_tx") is None +@pytest.mark.parametrize( + "config_entry_options", + [ + { + CONF_ALLOW_BANDWIDTH_SENSORS: False, + CONF_ALLOW_UPTIME_SENSORS: False, + CONF_TRACK_CLIENTS: False, + CONF_TRACK_DEVICES: False, + } + ], +) +@pytest.mark.parametrize( + "device_payload", + [ + [ + { + "board_rev": 2, + "device_id": "mock-id", + "ip": "10.0.1.1", + "mac": "10:00:00:00:01:01", + "last_seen": 1562600145, + "model": "US16P150", + "name": "mock-name", + "port_overrides": [], + "port_table": [ + { + "media": "GE", + "name": "Port 1", + "port_idx": 1, + "poe_class": "Class 4", + "poe_enable": False, + "poe_mode": "auto", + "poe_power": "2.56", + "poe_voltage": "53.40", + "portconf_id": "1a1", + "port_poe": False, + "up": True, + "speed": 1000, + }, + { + "media": "GE", + "name": "Port 2", + "port_idx": 2, + "poe_class": "Class 4", + "poe_enable": False, + "poe_mode": "auto", + "poe_power": "2.56", + "poe_voltage": "53.40", + "portconf_id": "1a2", + "port_poe": False, + "up": True, + "speed": 100, + }, + { + "media": "GE", + "name": "Port 3", + "port_idx": 3, + "poe_class": "Unknown", + "poe_enable": False, + "poe_mode": "off", + "poe_power": "0.00", + "poe_voltage": "0.00", + "portconf_id": "1a3", + "port_poe": False, + "up": False, + "speed": 0, + }, + ], + "state": 1, + "type": "usw", + "version": "4.0.42.10433", + } + ] + ], +) +@pytest.mark.usefixtures("config_entry_setup") +async def test_port_link_speed_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_websocket_message: WebsocketMessageMock, + device_payload: list[dict[str, Any]], +) -> None: + """Verify that port link speed sensors are working as expected.""" + p1_reg_entry = entity_registry.async_get("sensor.mock_name_port_1_link_speed") + assert p1_reg_entry.disabled_by == RegistryEntryDisabler.INTEGRATION + + p2_reg_entry = entity_registry.async_get("sensor.mock_name_port_2_link_speed") + assert p2_reg_entry.disabled_by == RegistryEntryDisabler.INTEGRATION + + # Port with speed 0 should not create an entity + assert not entity_registry.async_get("sensor.mock_name_port_3_link_speed") + + # Enable entity + entity_registry.async_update_entity( + entity_id="sensor.mock_name_port_1_link_speed", disabled_by=None + ) + entity_registry.async_update_entity( + entity_id="sensor.mock_name_port_2_link_speed", disabled_by=None + ) + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + # Verify sensor state + assert hass.states.get("sensor.mock_name_port_1_link_speed").state == "1000" + assert hass.states.get("sensor.mock_name_port_2_link_speed").state == "100" + + # Verify state update + device_1 = device_payload[0] + device_1["port_table"][0]["speed"] = 100 + + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) + await hass.async_block_till_done() + + assert hass.states.get("sensor.mock_name_port_1_link_speed").state == "100" + + @pytest.mark.parametrize( "device_payload", [ diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 3f4dc07c6aa5cb..0880577b571a93 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -10,6 +10,7 @@ from homeassistant import config_entries from homeassistant.components import usb +from homeassistant.components.usb import DOMAIN from homeassistant.components.usb.models import USBDevice from homeassistant.components.usb.utils import scan_serial_ports, usb_device_from_path from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP @@ -84,7 +85,7 @@ def async_register_callback(callback): ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -159,7 +160,7 @@ def scan_serial_ports() -> list: patch_scanned_serial_ports(side_effect=scan_serial_ports) as mock_ports, patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -197,7 +198,7 @@ async def test_removal_by_aiousbwatcher_before_started(hass: HomeAssistant) -> N patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() with patch_scanned_serial_ports(return_value=[]): @@ -233,7 +234,7 @@ async def test_discovered_by_websocket_scan( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -272,7 +273,7 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -312,7 +313,7 @@ async def test_most_targeted_matcher_wins( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -351,7 +352,7 @@ async def test_discovered_by_websocket_scan_rejected_by_description_matcher( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -394,7 +395,7 @@ async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -433,7 +434,7 @@ async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -476,7 +477,7 @@ async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -520,7 +521,7 @@ async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -558,7 +559,7 @@ async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -594,7 +595,7 @@ async def test_discovered_by_websocket_scan_match_vid_only( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -631,7 +632,7 @@ async def test_discovered_by_websocket_scan_match_vid_wrong_pid( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -667,7 +668,7 @@ async def test_discovered_by_websocket_no_vid_pid( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -703,7 +704,7 @@ async def test_non_matching_discovered_by_scanner_after_started( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -738,7 +739,7 @@ async def test_aiousbwatcher_on_wsl_fallback_without_throwing_exception( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -785,7 +786,7 @@ def async_register_callback(callback): ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -897,7 +898,7 @@ async def test_web_socket_triggers_discovery_request_callbacks( patch_scanned_serial_ports(return_value=[]), patch.object(hass.config_entries.flow, "async_init"), ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -933,7 +934,7 @@ async def test_initial_scan_callback( patch_scanned_serial_ports(return_value=[]), patch.object(hass.config_entries.flow, "async_init"), ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) cancel_1 = usb.async_register_initial_scan_callback(hass, mock_callback_1) assert len(mock_callback_1.mock_calls) == 0 @@ -968,7 +969,7 @@ async def test_cancel_initial_scan_callback( patch_scanned_serial_ports(return_value=[]), patch.object(hass.config_entries.flow, "async_init"), ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) cancel = usb.async_register_initial_scan_callback(hass, mock_callback) assert len(mock_callback.mock_calls) == 0 @@ -1071,7 +1072,7 @@ async def test_cp2102n_ordering_on_macos( patch_scanned_serial_ports(return_value=ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -1122,7 +1123,7 @@ async def test_register_port_event_callback( with ( patch_scanned_serial_ports(return_value=[]), ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) _cancel1 = usb.async_register_port_event_callback(hass, mock_callback1) cancel2 = usb.async_register_port_event_callback(hass, mock_callback2) @@ -1217,7 +1218,7 @@ async def test_register_port_event_callback_failure( with ( patch_scanned_serial_ports(return_value=[]), ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) usb.async_register_port_event_callback(hass, mock_callback1) usb.async_register_port_event_callback(hass, mock_callback2) @@ -1490,7 +1491,7 @@ async def async_step_confirm(self, user_input=None): mock_config_flow("test1", TestFlow), mock_config_flow("test2", TestFlow), ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() diff --git a/tests/components/velux/__init__.py b/tests/components/velux/__init__.py index 931469d213e296..cd190d3ce7b44f 100644 --- a/tests/components/velux/__init__.py +++ b/tests/components/velux/__init__.py @@ -15,8 +15,9 @@ async def update_callback_entity( ) -> None: """Simulate an update triggered by the pyvlx lib for a Velux node.""" - callback = mock_velux_node.register_device_updated_cb.call_args[0][0] - await callback(mock_velux_node) + for c in mock_velux_node.register_device_updated_cb.call_args_list: + callback = c[0][0] + await callback(mock_velux_node) await hass.async_block_till_done() diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index c14911bec00370..2c84ca77af34cc 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -4,7 +4,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from pyvlx import Blind, Light, OnOffLight, OnOffSwitch, Scene, Window +from pyvlx import Light, OnOffLight, OnOffSwitch, Scene +from pyvlx.opening_device import Blind, DualRollerShutter, Window from homeassistant.components.velux import DOMAIN from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, Platform @@ -69,6 +70,22 @@ def mock_window() -> AsyncMock: return window +# a dual roller shutter +@pytest.fixture +def mock_dual_roller_shutter() -> AsyncMock: + """Create a mock Velux dual roller shutter.""" + cover = AsyncMock(spec=DualRollerShutter, autospec=True) + cover.name = "Test Dual Roller Shutter" + cover.serial_number = "987654321" + cover.is_opening = False + cover.is_closing = False + cover.position_upper_curtain = MagicMock(position_percent=30, closed=False) + cover.position_lower_curtain = MagicMock(position_percent=30, closed=False) + cover.position = MagicMock(position_percent=30, closed=False) + cover.pyvlx = MagicMock() + return cover + + # a blind @pytest.fixture def mock_blind() -> AsyncMock: @@ -137,6 +154,8 @@ def mock_cover_type(request: pytest.FixtureRequest) -> AsyncMock: cover.is_opening = False cover.is_closing = False cover.position = MagicMock(position_percent=30, closed=False) + cover.position_upper_curtain = MagicMock(position_percent=30, closed=False) + cover.position_lower_curtain = MagicMock(position_percent=30, closed=False) cover.pyvlx = MagicMock() return cover @@ -149,6 +168,7 @@ def mock_pyvlx( mock_onoff_switch: AsyncMock, mock_window: AsyncMock, mock_blind: AsyncMock, + mock_dual_roller_shutter: AsyncMock, request: pytest.FixtureRequest, ) -> Generator[MagicMock]: """Create the library mock and patch PyVLX in both component and config_flow. @@ -164,6 +184,7 @@ def mock_pyvlx( pyvlx.nodes = [request.getfixturevalue(request.param)] else: pyvlx.nodes = [ + mock_dual_roller_shutter, mock_light, mock_onoff_light, mock_onoff_switch, diff --git a/tests/components/velux/snapshots/test_cover.ambr b/tests/components/velux/snapshots/test_cover.ambr index e6e9fa5f4f7fdd..2e2d0fae52c762 100644 --- a/tests/components/velux/snapshots/test_cover.ambr +++ b/tests/components/velux/snapshots/test_cover.ambr @@ -104,6 +104,162 @@ 'state': 'open', }) # --- +# name: test_cover_entity_setup[mock_cover_type-DualRollerShutter][cover.test_dualrollershutter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_dualrollershutter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'serial_DualRollerShutter', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entity_setup[mock_cover_type-DualRollerShutter][cover.test_dualrollershutter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 70, + 'device_class': 'shutter', + 'friendly_name': 'Test DualRollerShutter', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_dualrollershutter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_entity_setup[mock_cover_type-DualRollerShutter][cover.test_dualrollershutter_lower_shutter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_dualrollershutter_lower_shutter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lower shutter', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lower shutter', + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'dual_roller_shutter_lower', + 'unique_id': 'serial_DualRollerShutter_lower', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entity_setup[mock_cover_type-DualRollerShutter][cover.test_dualrollershutter_lower_shutter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 70, + 'device_class': 'shutter', + 'friendly_name': 'Test DualRollerShutter Lower shutter', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_dualrollershutter_lower_shutter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_entity_setup[mock_cover_type-DualRollerShutter][cover.test_dualrollershutter_upper_shutter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_dualrollershutter_upper_shutter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Upper shutter', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upper shutter', + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'dual_roller_shutter_upper', + 'unique_id': 'serial_DualRollerShutter_upper', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entity_setup[mock_cover_type-DualRollerShutter][cover.test_dualrollershutter_upper_shutter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 70, + 'device_class': 'shutter', + 'friendly_name': 'Test DualRollerShutter Upper shutter', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_dualrollershutter_upper_shutter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- # name: test_cover_entity_setup[mock_cover_type-GarageDoor][cover.test_garagedoor-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/velux/test_cover.py b/tests/components/velux/test_cover.py index 39df7d25ad09a8..a2620aac31dc41 100644 --- a/tests/components/velux/test_cover.py +++ b/tests/components/velux/test_cover.py @@ -4,7 +4,14 @@ import pytest from pyvlx.exception import PyVLXException -from pyvlx.opening_device import Awning, GarageDoor, Gate, RollerShutter, Window +from pyvlx.opening_device import ( + Awning, + DualRollerShutter, + GarageDoor, + Gate, + RollerShutter, + Window, +) from homeassistant.components.cover import ( ATTR_POSITION, @@ -64,7 +71,9 @@ async def test_blind_entity_setup( @pytest.mark.usefixtures("mock_cover_type") @pytest.mark.parametrize( - "mock_cover_type", [Awning, GarageDoor, Gate, RollerShutter, Window], indirect=True + "mock_cover_type", + [Awning, DualRollerShutter, GarageDoor, Gate, RollerShutter, Window], + indirect=True, ) @pytest.mark.parametrize( "mock_pyvlx", @@ -103,7 +112,13 @@ async def test_cover_device_association( assert entry.device_id is not None device_entry = device_registry.async_get(entry.device_id) assert device_entry is not None - assert (DOMAIN, entry.unique_id) in device_entry.identifiers + + # For dual roller shutters, the unique_id is suffixed with "_upper" or "_lower", + # so remove that suffix to get the domain_id for device registry lookup + domain_id = entry.unique_id + if entry.unique_id.endswith("_upper") or entry.unique_id.endswith("_lower"): + domain_id = entry.unique_id.rsplit("_", 1)[0] + assert (DOMAIN, domain_id) in device_entry.identifiers assert device_entry.via_device_id is not None via_device_entry = device_registry.async_get(device_entry.via_device_id) assert via_device_entry is not None @@ -220,6 +235,165 @@ async def test_window_current_position_and_opening_closing_states( assert state.state == STATE_CLOSING +# Dual roller shutter command tests +async def test_dual_roller_shutter_open_close_services( + hass: HomeAssistant, mock_dual_roller_shutter: AsyncMock +) -> None: + """Verify open/close services map to device calls with correct part.""" + + dual_entity_id = "cover.test_dual_roller_shutter" + upper_entity_id = "cover.test_dual_roller_shutter_upper_shutter" + lower_entity_id = "cover.test_dual_roller_shutter_lower_shutter" + + # Open upper part + await hass.services.async_call( + COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": upper_entity_id}, blocking=True + ) + mock_dual_roller_shutter.open.assert_awaited_with( + curtain="upper", wait_for_completion=False + ) + + # Open lower part + await hass.services.async_call( + COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": lower_entity_id}, blocking=True + ) + mock_dual_roller_shutter.open.assert_awaited_with( + curtain="lower", wait_for_completion=False + ) + + # Open dual + await hass.services.async_call( + COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": dual_entity_id}, blocking=True + ) + mock_dual_roller_shutter.open.assert_awaited_with( + curtain="dual", wait_for_completion=False + ) + + # Close upper part + await hass.services.async_call( + COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": upper_entity_id}, blocking=True + ) + mock_dual_roller_shutter.close.assert_awaited_with( + curtain="upper", wait_for_completion=False + ) + + # Close lower part + await hass.services.async_call( + COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": lower_entity_id}, blocking=True + ) + mock_dual_roller_shutter.close.assert_awaited_with( + curtain="lower", wait_for_completion=False + ) + + # Close dual + await hass.services.async_call( + COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": dual_entity_id}, blocking=True + ) + mock_dual_roller_shutter.close.assert_awaited_with( + curtain="dual", wait_for_completion=False + ) + + +async def test_dual_shutter_set_cover_position_inversion( + hass: HomeAssistant, mock_dual_roller_shutter: AsyncMock +) -> None: + """HA position is inverted for device's Position.""" + + entity_id = "cover.test_dual_roller_shutter" + # Call with position 30 (=70% for device) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {"entity_id": entity_id, ATTR_POSITION: 30}, + blocking=True, + ) + + # Expect device Position 70% + args, kwargs = mock_dual_roller_shutter.set_position.await_args + position_obj = args[0] + assert position_obj.position_percent == 70 + assert kwargs.get("wait_for_completion") is False + assert kwargs.get("curtain") == "dual" + + entity_id = "cover.test_dual_roller_shutter_upper_shutter" + # Call with position 30 (=70% for device) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {"entity_id": entity_id, ATTR_POSITION: 30}, + blocking=True, + ) + + # Expect device Position 70% + args, kwargs = mock_dual_roller_shutter.set_position.await_args + position_obj = args[0] + assert position_obj.position_percent == 70 + assert kwargs.get("wait_for_completion") is False + assert kwargs.get("curtain") == "upper" + + entity_id = "cover.test_dual_roller_shutter_lower_shutter" + # Call with position 30 (=70% for device) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {"entity_id": entity_id, ATTR_POSITION: 30}, + blocking=True, + ) + + # Expect device Position 70% + args, kwargs = mock_dual_roller_shutter.set_position.await_args + position_obj = args[0] + assert position_obj.position_percent == 70 + assert kwargs.get("wait_for_completion") is False + assert kwargs.get("curtain") == "lower" + + +async def test_dual_roller_shutter_position_tests( + hass: HomeAssistant, mock_dual_roller_shutter: AsyncMock +) -> None: + """Validate current_position and open/closed state.""" + + entity_id_dual = "cover.test_dual_roller_shutter" + entity_id_lower = "cover.test_dual_roller_shutter_lower_shutter" + entity_id_upper = "cover.test_dual_roller_shutter_upper_shutter" + + # device position is inverted (100 - x) + mock_dual_roller_shutter.position.position_percent = 29 + mock_dual_roller_shutter.position_upper_curtain.position_percent = 28 + mock_dual_roller_shutter.position_lower_curtain.position_percent = 27 + await update_callback_entity(hass, mock_dual_roller_shutter) + state = hass.states.get(entity_id_dual) + assert state is not None + assert state.attributes.get("current_position") == 71 + assert state.state == STATE_OPEN + + state = hass.states.get(entity_id_upper) + assert state is not None + assert state.attributes.get("current_position") == 72 + assert state.state == STATE_OPEN + + state = hass.states.get(entity_id_lower) + assert state is not None + assert state.attributes.get("current_position") == 73 + assert state.state == STATE_OPEN + + mock_dual_roller_shutter.position.closed = True + mock_dual_roller_shutter.position_upper_curtain.closed = True + mock_dual_roller_shutter.position_lower_curtain.closed = True + await update_callback_entity(hass, mock_dual_roller_shutter) + state = hass.states.get(entity_id_dual) + assert state is not None + assert state.state == STATE_CLOSED + + state = hass.states.get(entity_id_upper) + assert state is not None + assert state.state == STATE_CLOSED + + state = hass.states.get(entity_id_lower) + assert state is not None + assert state.state == STATE_CLOSED + + # Blind command tests diff --git a/tests/components/watts/test_config_flow.py b/tests/components/watts/test_config_flow.py index 8b56bda1ae1e3a..67c9fbf64a63f6 100644 --- a/tests/components/watts/test_config_flow.py +++ b/tests/components/watts/test_config_flow.py @@ -197,6 +197,109 @@ async def test_oauth_invalid_response( assert result.get("reason") == "oauth_failed" +@pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry") +async def test_reauth_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reauthentication flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + + with patch( + "homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token", + return_value="test-user-id", + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + mock_config_entry.data["token"].pop("expires_at") + assert mock_config_entry.data["token"] == { + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 3600, + } + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_account_mismatch( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauthentication with a different account aborts.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + + with patch( + "homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token", + return_value="different-user-id", + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_account_mismatch" + + @pytest.mark.usefixtures("current_request_with_host") async def test_unique_config_entry( hass: HomeAssistant, diff --git a/tests/components/wled/snapshots/test_sensor.ambr b/tests/components/wled/snapshots/test_sensor.ambr index d9430bb4fa9887..7cfe508527ffa4 100644 --- a/tests/components/wled/snapshots/test_sensor.ambr +++ b/tests/components/wled/snapshots/test_sensor.ambr @@ -195,13 +195,14 @@ 'supported_features': 0, 'translation_key': 'info_leds_count', 'unique_id': 'aabbccddeeff_info_leds_count', - 'unit_of_measurement': None, + 'unit_of_measurement': 'LEDs', }) # --- # name: test_snapshots[sensor.wled_rgb_light_led_count-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'WLED RGB Light LED count', + 'unit_of_measurement': 'LEDs', }), 'context': , 'entity_id': 'sensor.wled_rgb_light_led_count', diff --git a/tests/components/zha/test_homeassistant_hardware.py b/tests/components/zha/test_homeassistant_hardware.py index 067126cec79a16..0e038fff113c8c 100644 --- a/tests/components/zha/test_homeassistant_hardware.py +++ b/tests/components/zha/test_homeassistant_hardware.py @@ -5,6 +5,9 @@ import pytest from zigpy.application import ControllerApplication +from homeassistant.components.homeassistant_hardware import ( + DOMAIN as HOMEASSISTANT_HARDWARE_DOMAIN, +) from homeassistant.components.homeassistant_hardware.helpers import ( async_register_firmware_info_callback, ) @@ -102,7 +105,7 @@ async def test_hardware_firmware_info_provider_notification( """Test that the ZHA gateway provides hardware and firmware information.""" config_entry.add_to_hass(hass) - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, HOMEASSISTANT_HARDWARE_DOMAIN, {}) callback = MagicMock() async_register_firmware_info_callback(hass, "/dev/ttyUSB0", callback) diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index 081cd333d8b4ca..50a5db9710032a 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -13,6 +13,9 @@ from zigpy.device import Device from zigpy.exceptions import TransientConnectionError +from homeassistant.components.homeassistant_hardware import ( + DOMAIN as HOMEASSISTANT_HARDWARE_DOMAIN, +) from homeassistant.components.homeassistant_hardware.helpers import ( async_is_firmware_update_in_progress, async_register_firmware_update_in_progress, @@ -330,7 +333,7 @@ async def test_setup_no_firmware_update_in_progress( mock_zigpy_connect: ControllerApplication, ) -> None: """Test that ZHA setup proceeds normally when no firmware update is in progress.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, HOMEASSISTANT_HARDWARE_DOMAIN, {}) config_entry.add_to_hass(hass) device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] @@ -345,7 +348,7 @@ async def test_setup_firmware_update_in_progress( config_entry: MockConfigEntry, ) -> None: """Test that ZHA setup is blocked when firmware update is in progress.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, HOMEASSISTANT_HARDWARE_DOMAIN, {}) config_entry.add_to_hass(hass) device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] @@ -362,7 +365,7 @@ async def test_setup_firmware_update_in_progress_prevents_silabs_warning( mock_zigpy_connect: ControllerApplication, ) -> None: """Test firmware update in progress prevents silabs firmware warning on setup failure.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, HOMEASSISTANT_HARDWARE_DOMAIN, {}) config_entry.add_to_hass(hass) device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] diff --git a/tests/fixtures/core/backup_restore/backup_from_future.tar b/tests/fixtures/core/backup_restore/backup_from_future.tar new file mode 100644 index 00000000000000..4e4b4545ff590e Binary files /dev/null and b/tests/fixtures/core/backup_restore/backup_from_future.tar differ diff --git a/tests/fixtures/core/backup_restore/empty_backup_database_included.tar b/tests/fixtures/core/backup_restore/empty_backup_database_included.tar new file mode 100644 index 00000000000000..0090e2237db1cd Binary files /dev/null and b/tests/fixtures/core/backup_restore/empty_backup_database_included.tar differ diff --git a/tests/fixtures/core/backup_restore/restore1.json b/tests/fixtures/core/backup_restore/restore1.json new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tests/fixtures/core/backup_restore/restore2.json b/tests/fixtures/core/backup_restore/restore2.json new file mode 100644 index 00000000000000..966c83c1d9d48d --- /dev/null +++ b/tests/fixtures/core/backup_restore/restore2.json @@ -0,0 +1 @@ +{ "path": "test" } diff --git a/tests/fixtures/core/backup_restore/restore3.json b/tests/fixtures/core/backup_restore/restore3.json new file mode 100644 index 00000000000000..f7e978111eb4ec --- /dev/null +++ b/tests/fixtures/core/backup_restore/restore3.json @@ -0,0 +1,7 @@ +{ + "path": "test", + "password": "psw", + "remove_after_restore": false, + "restore_database": false, + "restore_homeassistant": true +} diff --git a/tests/fixtures/core/backup_restore/restore4.json b/tests/fixtures/core/backup_restore/restore4.json new file mode 100644 index 00000000000000..143fa2f6a87c36 --- /dev/null +++ b/tests/fixtures/core/backup_restore/restore4.json @@ -0,0 +1,7 @@ +{ + "path": "test", + "password": null, + "remove_after_restore": true, + "restore_database": true, + "restore_homeassistant": false +} diff --git a/tests/test_backup_restore.py b/tests/test_backup_restore.py index 7efe25c8428578..2d66e90be5e81c 100644 --- a/tests/test_backup_restore.py +++ b/tests/test_backup_restore.py @@ -1,6 +1,5 @@ """Test methods in backup_restore.""" -from collections.abc import Generator import json from pathlib import Path import tarfile @@ -11,39 +10,36 @@ from homeassistant import backup_restore -from .common import get_test_config_dir +from .common import get_fixture_path -@pytest.fixture(autouse=True) -def remove_restore_result_file() -> Generator[None]: - """Remove the restore result file.""" - yield - Path(get_test_config_dir(".HA_RESTORE_RESULT")).unlink(missing_ok=True) - - -def restore_result_file_content() -> dict[str, Any] | None: +def restore_result_file_content(config_dir: Path) -> dict[str, Any] | None: """Return the content of the restore result file.""" try: - return json.loads( - Path(get_test_config_dir(".HA_RESTORE_RESULT")).read_text("utf-8") - ) + return json.loads((config_dir / ".HA_RESTORE_RESULT").read_text("utf-8")) except FileNotFoundError: return None @pytest.mark.parametrize( - ("side_effect", "content", "expected"), + ("restore_config", "expected", "restore_result"), [ - (FileNotFoundError, "", None), - (None, "", None), ( + "restore1.json", # Empty file, so JSONDecodeError is expected None, - '{"path": "test"}', - None, + { + "success": False, + "error": "Expecting value: line 1 column 1 (char 0)", + "error_type": "JSONDecodeError", + }, ), ( + "restore2.json", # File missing the 'password' key, so KeyError is expected None, - '{"path": "test", "password": "psw", "remove_after_restore": false, "restore_database": false, "restore_homeassistant": true}', + {"success": False, "error": "'password'", "error_type": "KeyError"}, + ), + ( + "restore3.json", # Valid file backup_restore.RestoreBackupFileContent( backup_file_path=Path("test"), password="psw", @@ -51,10 +47,10 @@ def restore_result_file_content() -> dict[str, Any] | None: restore_database=False, restore_homeassistant=True, ), + None, ), ( - None, - '{"path": "test", "password": null, "remove_after_restore": true, "restore_database": true, "restore_homeassistant": false}', + "restore4.json", # Valid file backup_restore.RestoreBackupFileContent( backup_file_path=Path("test"), password=None, @@ -62,128 +58,180 @@ def restore_result_file_content() -> dict[str, Any] | None: restore_database=True, restore_homeassistant=False, ), + None, ), ], ) def test_reading_the_instruction_contents( - side_effect: Exception | None, - content: str, + restore_config: str, expected: backup_restore.RestoreBackupFileContent | None, + restore_result: dict[str, Any] | None, + tmp_path: Path, ) -> None: """Test reading the content of the .HA_RESTORE file.""" - with ( - mock.patch( - "pathlib.Path.read_text", - return_value=content, - side_effect=side_effect, - ), - mock.patch("pathlib.Path.unlink", autospec=True) as unlink_mock, - ): - config_path = Path(get_test_config_dir()) - read_content = backup_restore.restore_backup_file_content(config_path) - assert read_content == expected - unlink_mock.assert_called_once_with( - config_path / ".HA_RESTORE", missing_ok=True - ) + get_fixture_path(f"core/backup_restore/{restore_config}", None).copy( + tmp_path / ".HA_RESTORE" + ) + restore_file_path = tmp_path / ".HA_RESTORE" + assert restore_file_path.exists() + + read_content = backup_restore.restore_backup_file_content(tmp_path) + assert read_content == expected + assert not restore_file_path.exists() + assert restore_result_file_content(tmp_path) == restore_result -def test_restoring_backup_that_does_not_exist() -> None: +def test_reading_the_instruction_contents_missing(tmp_path: Path) -> None: + """Test reading the content of the .HA_RESTORE file when it is missing.""" + assert not (tmp_path / ".HA_RESTORE").exists() + + read_content = backup_restore.restore_backup_file_content(tmp_path) + assert read_content is None + assert not (tmp_path / ".HA_RESTORE").exists() + assert restore_result_file_content(tmp_path) is None + + +@pytest.mark.parametrize( + ("restore_config"), + [ + "restore3.json", + "restore4.json", + ], +) +def test_restoring_backup_that_does_not_exist( + restore_config: str, tmp_path: Path +) -> None: """Test restoring a backup that does not exist.""" - backup_file_path = Path(get_test_config_dir("backups", "test")) + get_fixture_path(f"core/backup_restore/{restore_config}", None).copy( + tmp_path / ".HA_RESTORE" + ) + restore_file_path = tmp_path / ".HA_RESTORE" + assert restore_file_path.exists() with ( - mock.patch( - "homeassistant.backup_restore.restore_backup_file_content", - return_value=backup_restore.RestoreBackupFileContent( - backup_file_path=backup_file_path, - password=None, - remove_after_restore=False, - restore_database=True, - restore_homeassistant=True, - ), - ), - mock.patch("pathlib.Path.read_text", side_effect=FileNotFoundError), - pytest.raises( - ValueError, match=f"Backup file {backup_file_path} does not exist" - ), + pytest.raises(ValueError, match="Backup file test does not exist"), ): - assert backup_restore.restore_backup(Path(get_test_config_dir())) is False - assert restore_result_file_content() == { - "error": f"Backup file {backup_file_path} does not exist", + assert backup_restore.restore_backup(tmp_path.as_posix()) is False + assert restore_result_file_content(tmp_path) == { + "error": "Backup file test does not exist", "error_type": "ValueError", "success": False, } -def test_restoring_backup_when_instructions_can_not_be_read() -> None: - """Test restoring a backup when instructions can not be read.""" - with ( - mock.patch( - "homeassistant.backup_restore.restore_backup_file_content", - return_value=None, +@pytest.mark.parametrize( + ("restore_config", "restore_result"), + [ + ( + "restore1.json", # Empty file, so JSONDecodeError is expected + { + "success": False, + "error": "Expecting value: line 1 column 1 (char 0)", + "error_type": "JSONDecodeError", + }, ), - ): - assert backup_restore.restore_backup(Path(get_test_config_dir())) is False - assert restore_result_file_content() is None + ( + "restore2.json", # File missing the 'password' key, so KeyError is expected + {"success": False, "error": "'password'", "error_type": "KeyError"}, + ), + ], +) +def test_restoring_backup_when_instructions_can_not_be_read( + restore_config: str, restore_result: dict[str, Any], tmp_path: Path +) -> None: + """Test restoring a backup when instructions can not be read.""" + get_fixture_path(f"core/backup_restore/{restore_config}", None).copy( + tmp_path / ".HA_RESTORE" + ) + restore_file_path = tmp_path / ".HA_RESTORE" + assert restore_file_path.exists() + assert backup_restore.restore_backup(tmp_path.as_posix()) is False + assert not restore_file_path.exists() + assert restore_result_file_content(tmp_path) == restore_result + + +def test_restoring_backup_when_instructions_missing(tmp_path: Path) -> None: + """Test restoring a backup when instructions are missing.""" + restore_file_path = tmp_path / ".HA_RESTORE" + assert not restore_file_path.exists() + assert backup_restore.restore_backup(tmp_path.as_posix()) is False + assert not restore_file_path.exists() + assert restore_result_file_content(tmp_path) is None -def test_restoring_backup_that_is_not_a_file() -> None: +@pytest.mark.parametrize( + ("restore_config"), + [ + "restore3.json", + "restore4.json", + ], +) +def test_restoring_backup_that_is_not_a_file( + restore_config: str, tmp_path: Path +) -> None: """Test restoring a backup that is not a file.""" - backup_file_path = Path(get_test_config_dir("backups", "test")) + backup_file_path = tmp_path / "test" + restore_file_path = tmp_path / ".HA_RESTORE" + + # Set up restore file to point to a file within the temporary directory + restore_config = json.load( + get_fixture_path(f"core/backup_restore/{restore_config}", None).open( + "r", encoding="utf-8" + ) + ) + restore_config["path"] = backup_file_path.as_posix() + json.dump(restore_config, restore_file_path.open("w", encoding="utf-8")) + assert restore_file_path.exists() + + # Create a directory at the backup file path to simulate the backup file not being a file + backup_file_path.mkdir(exist_ok=True) + with ( - mock.patch( - "homeassistant.backup_restore.restore_backup_file_content", - return_value=backup_restore.RestoreBackupFileContent( - backup_file_path=backup_file_path, - password=None, - remove_after_restore=False, - restore_database=True, - restore_homeassistant=True, - ), - ), - mock.patch("pathlib.Path.exists", return_value=True), - mock.patch("pathlib.Path.is_file", return_value=False), - pytest.raises( - ValueError, match=f"Backup file {backup_file_path} does not exist" - ), + pytest.raises(IsADirectoryError, match="\\[Errno 21\\] Is a directory"), ): - assert backup_restore.restore_backup(Path(get_test_config_dir())) is False - assert restore_result_file_content() == { - "error": f"Backup file {backup_file_path} does not exist", - "error_type": "ValueError", + assert backup_restore.restore_backup(tmp_path.as_posix()) is False + restore_result = restore_result_file_content(tmp_path) + assert restore_result == { + "error": mock.ANY, + "error_type": "IsADirectoryError", "success": False, } + assert restore_result["error"].startswith("[Errno 21] Is a directory:") -def test_aborting_for_older_versions() -> None: +@pytest.mark.parametrize( + ("restore_config"), + [ + "restore3.json", + "restore4.json", + ], +) +def test_aborting_for_older_versions(restore_config: str, tmp_path: Path) -> None: """Test that we abort for older versions.""" - config_dir = Path(get_test_config_dir()) - backup_file_path = Path(config_dir, "backups", "test.tar") + backup_file_path = tmp_path / "backup_from_future.tar" + restore_file_path = tmp_path / ".HA_RESTORE" - def _patched_path_read_text(path: Path, **kwargs): - return '{"homeassistant": {"version": "9999.99.99"}, "compressed": false}' + # Set up restore file to point to a file within the temporary directory + restore_config = json.load( + get_fixture_path(f"core/backup_restore/{restore_config}", None).open( + "r", encoding="utf-8" + ) + ) + restore_config["path"] = backup_file_path.as_posix() + json.dump(restore_config, restore_file_path.open("w", encoding="utf-8")) + assert restore_file_path.exists() + + get_fixture_path("core/backup_restore/backup_from_future.tar", None).copy_into( + tmp_path + ) with ( - mock.patch( - "homeassistant.backup_restore.restore_backup_file_content", - return_value=backup_restore.RestoreBackupFileContent( - backup_file_path=backup_file_path, - password=None, - remove_after_restore=False, - restore_database=True, - restore_homeassistant=True, - ), - ), - mock.patch("securetar.SecureTarFile"), - mock.patch("homeassistant.backup_restore.TemporaryDirectory"), - mock.patch("pathlib.Path.read_text", _patched_path_read_text), - mock.patch("homeassistant.backup_restore.HA_VERSION", "2013.09.17"), pytest.raises( ValueError, match="You need at least Home Assistant version 9999.99.99 to restore this backup", ), ): - assert backup_restore.restore_backup(config_dir) is True - assert restore_result_file_content() == { + assert backup_restore.restore_backup(tmp_path.as_posix()) is True + assert restore_result_file_content(tmp_path) == { "error": ( "You need at least Home Assistant version 9999.99.99 to restore this backup" ), @@ -195,10 +243,9 @@ def _patched_path_read_text(path: Path, **kwargs): @pytest.mark.parametrize( ( "restore_backup_content", - "expected_removed_files", - "expected_removed_directories", - "expected_copied_files", - "expected_copied_trees", + "expected_kept_files", + "expected_restored_files", + "expected_directories_after_restore", ), [ ( @@ -209,15 +256,9 @@ def _patched_path_read_text(path: Path, **kwargs): restore_database=True, restore_homeassistant=True, ), - ( - ".HA_RESTORE", - ".HA_VERSION", - "home-assistant_v2.db", - "home-assistant_v2.db-wal", - ), - ("tmp_backups", "www"), - (), - ("data",), + {"backups/test.tar"}, + {"home-assistant_v2.db", "home-assistant_v2.db-wal"}, + {"backups"}, ), ( backup_restore.RestoreBackupFileContent( @@ -227,10 +268,9 @@ def _patched_path_read_text(path: Path, **kwargs): remove_after_restore=False, restore_homeassistant=True, ), - (".HA_RESTORE", ".HA_VERSION"), - ("tmp_backups", "www"), - (), - ("data",), + {"backups/test.tar", "home-assistant_v2.db", "home-assistant_v2.db-wal"}, + set(), + {"backups"}, ), ( backup_restore.RestoreBackupFileContent( @@ -240,109 +280,124 @@ def _patched_path_read_text(path: Path, **kwargs): remove_after_restore=False, restore_homeassistant=False, ), - ("home-assistant_v2.db", "home-assistant_v2.db-wal"), - (), - ("home-assistant_v2.db", "home-assistant_v2.db-wal"), - (), + {".HA_RESTORE", ".HA_VERSION", "backups/test.tar"}, + {"home-assistant_v2.db", "home-assistant_v2.db-wal"}, + {"backups", "tmp_backups", "www"}, ), ], ) -def test_removal_of_current_configuration_when_restoring( +def test_restore_backup( restore_backup_content: backup_restore.RestoreBackupFileContent, - expected_removed_files: tuple[str, ...], - expected_removed_directories: tuple[str, ...], - expected_copied_files: tuple[str, ...], - expected_copied_trees: tuple[str, ...], + expected_kept_files: set[str], + expected_restored_files: set[str], + expected_directories_after_restore: set[str], + tmp_path: Path, ) -> None: - """Test that we are removing the current configuration directory.""" - config_dir = Path(get_test_config_dir()) - restore_backup_content.backup_file_path = Path(config_dir, "backups", "test.tar") - mock_config_dir = [ - {"path": Path(config_dir, ".HA_RESTORE"), "is_file": True}, - {"path": Path(config_dir, ".HA_VERSION"), "is_file": True}, - {"path": Path(config_dir, "home-assistant_v2.db"), "is_file": True}, - {"path": Path(config_dir, "home-assistant_v2.db-wal"), "is_file": True}, - {"path": Path(config_dir, "backups"), "is_file": False}, - {"path": Path(config_dir, "tmp_backups"), "is_file": False}, - {"path": Path(config_dir, "www"), "is_file": False}, - ] - - def _patched_path_read_text(path: Path, **kwargs): - return '{"homeassistant": {"version": "2013.09.17"}, "compressed": false}' - - def _patched_path_is_file(path: Path, **kwargs): - return [x for x in mock_config_dir if x["path"] == path][0]["is_file"] - - def _patched_path_is_dir(path: Path, **kwargs): - return not [x for x in mock_config_dir if x["path"] == path][0]["is_file"] + """Test restoring a backup. + + This includes checking that expected files are kept, restored, and + that we are cleaning up the current configuration directory. + """ + backup_file_path = tmp_path / "backups" / "test.tar" + + def get_files(path: Path) -> set[str]: + """Get all files under path.""" + return {str(f.relative_to(path)) for f in path.rglob("*")} + + existing_dirs = { + "backups", + "tmp_backups", + "www", + } + existing_files = { + ".HA_RESTORE", + ".HA_VERSION", + "home-assistant_v2.db", + "home-assistant_v2.db-wal", + } + + for d in existing_dirs: + (tmp_path / d).mkdir(exist_ok=True) + for f in existing_files: + (tmp_path / f).write_text("before_restore") + + get_fixture_path( + "core/backup_restore/empty_backup_database_included.tar", None + ).copy(backup_file_path) + + files_before_restore = get_files(tmp_path) + assert files_before_restore == { + ".HA_RESTORE", + ".HA_VERSION", + "backups", + "backups/test.tar", + "home-assistant_v2.db", + "home-assistant_v2.db-wal", + "tmp_backups", + "www", + } + kept_files_data = {} + for file in expected_kept_files: + kept_files_data[file] = (tmp_path / file).read_bytes() + + restore_backup_content.backup_file_path = backup_file_path with ( mock.patch( "homeassistant.backup_restore.restore_backup_file_content", return_value=restore_backup_content, ), - mock.patch("securetar.SecureTarFile"), - mock.patch("homeassistant.backup_restore.TemporaryDirectory") as temp_dir_mock, - mock.patch("homeassistant.backup_restore.HA_VERSION", "2013.09.17"), - mock.patch("pathlib.Path.read_text", _patched_path_read_text), - mock.patch("pathlib.Path.is_file", _patched_path_is_file), - mock.patch("pathlib.Path.is_dir", _patched_path_is_dir), - mock.patch( - "pathlib.Path.iterdir", - return_value=[x["path"] for x in mock_config_dir], - ), - mock.patch("pathlib.Path.unlink", autospec=True) as unlink_mock, - mock.patch("shutil.copy") as copy_mock, - mock.patch("shutil.copytree") as copytree_mock, - mock.patch("shutil.rmtree") as rmtree_mock, ): - temp_dir_mock.return_value.__enter__.return_value = "tmp" - - assert backup_restore.restore_backup(config_dir) is True - - tmp_ha = Path("tmp", "homeassistant") - assert copy_mock.call_count == len(expected_copied_files) - copied_files = {Path(call.args[0]) for call in copy_mock.mock_calls} - assert copied_files == {Path(tmp_ha, "data", f) for f in expected_copied_files} - - assert copytree_mock.call_count == len(expected_copied_trees) - copied_trees = {Path(call.args[0]) for call in copytree_mock.mock_calls} - assert copied_trees == {Path(tmp_ha, t) for t in expected_copied_trees} + assert backup_restore.restore_backup(tmp_path.as_posix()) is True + + files_after_restore = get_files(tmp_path) + assert ( + files_after_restore + == {".HA_RESTORE_RESULT"} + | expected_kept_files + | expected_restored_files + | expected_directories_after_restore + ) - assert unlink_mock.call_count == len(expected_removed_files) - removed_files = {Path(call.args[0]) for call in unlink_mock.mock_calls} - assert removed_files == {Path(config_dir, f) for f in expected_removed_files} + for d in expected_directories_after_restore: + assert (tmp_path / d).is_dir() + for file in expected_kept_files: + assert (tmp_path / file).read_bytes() == kept_files_data[file] + for file in expected_restored_files: + assert (tmp_path / file).read_bytes() == b"restored_from_backup" - assert rmtree_mock.call_count == len(expected_removed_directories) - removed_directories = {Path(call.args[0]) for call in rmtree_mock.mock_calls} - assert removed_directories == { - Path(config_dir, d) for d in expected_removed_directories - } - assert restore_result_file_content() == { + assert restore_result_file_content(tmp_path) == { "error": None, "error_type": None, "success": True, } -def test_extracting_the_contents_of_a_backup_file() -> None: - """Test extracting the contents of a backup file.""" - config_dir = Path(get_test_config_dir()) - backup_file_path = Path(config_dir, "backups", "test.tar") +def test_restore_backup_filter_files(tmp_path: Path) -> None: + """Test filtering dangerous files when restoring a backup.""" + backup_file_path = tmp_path / "backups" / "test.tar" + backup_file_path.parent.mkdir() + get_fixture_path( + "core/backup_restore/empty_backup_database_included.tar", None + ).copy(backup_file_path) - def _patched_path_read_text(path: Path, **kwargs): - return '{"homeassistant": {"version": "2013.09.17"}, "compressed": false}' + with ( + tarfile.open(backup_file_path, "r") as outer_tar, + tarfile.open( + fileobj=outer_tar.extractfile("homeassistant.tar.gz"), mode="r|gz" + ) as inner_tar, + ): + member_names = {member.name for member in inner_tar.getmembers()} + assert member_names == { + ".", + "../bad_file_with_parent_link", + "/bad_absolute_file", + "data", + "data/home-assistant_v2.db", + "data/home-assistant_v2.db-wal", + } - getmembers_mock = mock.MagicMock( - return_value=[ - tarfile.TarInfo(name="../data/test"), - tarfile.TarInfo(name="data"), - tarfile.TarInfo(name="data/.HA_VERSION"), - tarfile.TarInfo(name="data/.storage"), - tarfile.TarInfo(name="data/www"), - ] - ) - extractall_mock = mock.MagicMock() + real_extractone = tarfile.TarFile._extract_one with ( mock.patch( @@ -356,41 +411,38 @@ def _patched_path_read_text(path: Path, **kwargs): ), ), mock.patch( - "tarfile.open", - return_value=mock.MagicMock( - getmembers=getmembers_mock, - extractall=extractall_mock, - __iter__=lambda x: iter(getmembers_mock.return_value), - ), - ), - mock.patch("homeassistant.backup_restore.TemporaryDirectory"), - mock.patch("pathlib.Path.read_text", _patched_path_read_text), - mock.patch("pathlib.Path.is_file", return_value=False), - mock.patch("pathlib.Path.iterdir", return_value=[]), - mock.patch("shutil.copytree"), + "tarfile.TarFile._extract_one", autospec=True, wraps=real_extractone + ) as extractone_mock, ): - assert backup_restore.restore_backup(config_dir) is True - assert extractall_mock.call_count == 2 - - assert { - member.name for member in extractall_mock.mock_calls[-1].kwargs["members"] - } == {"data", "data/.HA_VERSION", "data/.storage", "data/www"} - assert restore_result_file_content() == { + assert backup_restore.restore_backup(tmp_path.as_posix()) is True + + # Check the unsafe files are not extracted, and that the safe files are extracted + extracted_files = {call.args[1].name for call in extractone_mock.mock_calls} + assert extracted_files == { + "./backup.json", # From the outer tar + "homeassistant.tar.gz", # From the outer tar + ".", + "data", + "data/home-assistant_v2.db", + "data/home-assistant_v2.db-wal", + } + assert restore_result_file_content(tmp_path) == { "error": None, "error_type": None, "success": True, } -@pytest.mark.parametrize( - ("remove_after_restore", "unlink_calls"), [(True, 1), (False, 0)] -) +@pytest.mark.parametrize(("remove_after_restore"), [True, False]) def test_remove_backup_file_after_restore( - remove_after_restore: bool, unlink_calls: int + remove_after_restore: bool, tmp_path: Path ) -> None: """Test removing a backup file after restore.""" - config_dir = Path(get_test_config_dir()) - backup_file_path = Path(config_dir, "backups", "test.tar") + backup_file_path = tmp_path / "backups" / "test.tar" + backup_file_path.parent.mkdir() + get_fixture_path( + "core/backup_restore/empty_backup_database_included.tar", None + ).copy(backup_file_path) with ( mock.patch( @@ -403,14 +455,10 @@ def test_remove_backup_file_after_restore( restore_homeassistant=True, ), ), - mock.patch("homeassistant.backup_restore._extract_backup"), - mock.patch("pathlib.Path.unlink", autospec=True) as mock_unlink, ): - assert backup_restore.restore_backup(config_dir) is True - assert mock_unlink.call_count == unlink_calls - for call in mock_unlink.mock_calls: - assert call.args[0] == backup_file_path - assert restore_result_file_content() == { + assert backup_restore.restore_backup(tmp_path.as_posix()) is True + assert backup_file_path.exists() == (not remove_after_restore) + assert restore_result_file_content(tmp_path) == { "error": None, "error_type": None, "success": True,