diff --git a/.strict-typing b/.strict-typing index 8c807501387396..f54a31c979acd1 100644 --- a/.strict-typing +++ b/.strict-typing @@ -330,6 +330,7 @@ homeassistant.components.isy994.* homeassistant.components.jellyfin.* homeassistant.components.jewish_calendar.* homeassistant.components.jvc_projector.* +homeassistant.components.kaco_modbus.* homeassistant.components.kaleidescape.* homeassistant.components.knocki.* homeassistant.components.knx.* @@ -566,6 +567,7 @@ homeassistant.components.streamlabswater.* homeassistant.components.stt.* homeassistant.components.suez_water.* homeassistant.components.sun.* +homeassistant.components.sunsynk.* homeassistant.components.surepetcare.* homeassistant.components.switch.* homeassistant.components.switch_as_x.* diff --git a/CODEOWNERS b/CODEOWNERS index e62ce07ea5b6e1..f277a7016fe9e3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -964,6 +964,8 @@ CLAUDE.md @home-assistant/core /tests/components/justnimbus/ @kvanzuijlen /homeassistant/components/jvc_projector/ @SteveEasley /tests/components/jvc_projector/ @SteveEasley +/homeassistant/components/kaco_modbus/ @g4bri3lDev +/tests/components/kaco_modbus/ @g4bri3lDev /homeassistant/components/kaiterra/ @Michsior14 /homeassistant/components/kaleidescape/ @SteveEasley /tests/components/kaleidescape/ @SteveEasley @@ -1804,6 +1806,8 @@ CLAUDE.md @home-assistant/core /tests/components/sun/ @home-assistant/core /homeassistant/components/sunricher_dali/ @niracler /tests/components/sunricher_dali/ @niracler +/homeassistant/components/sunsynk/ @jamesridgway +/tests/components/sunsynk/ @jamesridgway /homeassistant/components/supla/ @mwegrzynek /homeassistant/components/surepetcare/ @benleb @danielhiversen /tests/components/surepetcare/ @benleb @danielhiversen diff --git a/homeassistant/components/dnsip/manifest.json b/homeassistant/components/dnsip/manifest.json index 04c5699b9aa72d..1014968b07f755 100644 --- a/homeassistant/components/dnsip/manifest.json +++ b/homeassistant/components/dnsip/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/dnsip", "integration_type": "service", "iot_class": "cloud_polling", + "quality_scale": "bronze", "requirements": ["aiodns==4.0.4"] } diff --git a/homeassistant/components/dnsip/quality_scale.yaml b/homeassistant/components/dnsip/quality_scale.yaml new file mode 100644 index 00000000000000..29188e5b966996 --- /dev/null +++ b/homeassistant/components/dnsip/quality_scale.yaml @@ -0,0 +1,90 @@ +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-conditions: + status: exempt + comment: Does not provide conditions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: Does not provide triggers + entity-event-setup: + status: exempt + comment: Entities do not subscribe to events; the sensor polls via async_update. + 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: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: DNS resolution does not require authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: DNS resolvers do not support discovery. + discovery: + status: exempt + comment: DNS resolvers do not support discovery. + docs-data-update: done + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: + status: exempt + comment: Integration does not represent physical devices. + docs-supported-functions: todo + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Integration does not represent physical devices. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repairs. + stale-devices: + status: exempt + comment: Integration does not represent physical devices. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: Integration uses aiodns directly; no shared HTTP websession applies. + strict-typing: todo diff --git a/homeassistant/components/duco/const.py b/homeassistant/components/duco/const.py index 74cddde6642b5c..e62921a958a15d 100644 --- a/homeassistant/components/duco/const.py +++ b/homeassistant/components/duco/const.py @@ -7,7 +7,7 @@ from homeassistant.const import Platform DOMAIN = "duco" -PLATFORMS = [Platform.FAN, Platform.SELECT, Platform.SENSOR] +PLATFORMS = [Platform.FAN, Platform.NUMBER, Platform.SELECT, Platform.SENSOR] SCAN_INTERVAL = timedelta(seconds=10) BOX_NODE_ID = 1 VENTILATION_CAPABLE_NODE_TYPES: tuple[NodeType, ...] = ( diff --git a/homeassistant/components/duco/coordinator.py b/homeassistant/components/duco/coordinator.py index e0755b153f0919..a07aa2501a13ef 100644 --- a/homeassistant/components/duco/coordinator.py +++ b/homeassistant/components/duco/coordinator.py @@ -14,6 +14,7 @@ ) from duco_connectivity.models import ( BoardInfo, + BypassSupplyTemperatureTarget, Node, NodeListActionItemList, NodeName, @@ -30,6 +31,7 @@ _LOGGER = logging.getLogger(__name__) + type DucoConfigEntry = ConfigEntry[DucoCoordinator] @@ -42,6 +44,7 @@ class DucoData: rssi_wifi: int | None time_filter_remain: int | None ventilation_temperatures: VentilationTemperatureInfo | None + bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget] class DucoCoordinator(DataUpdateCoordinator[DucoData]): @@ -51,6 +54,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]): board_info: BoardInfo _supports_time_filter_remain: bool _supports_ventilation_temperatures: bool + _supports_bypass_supply_temperature_targets: bool _configured_node_names: dict[int, str] def __init__( @@ -71,6 +75,7 @@ def __init__( self._configured_node_names = {} self._supports_time_filter_remain = True self._supports_ventilation_temperatures = True + self._supports_bypass_supply_temperature_targets = True async def _async_load_node_names(self) -> None: """Load configured Duco node names during setup.""" @@ -201,10 +206,31 @@ async def _async_update_data(self) -> DucoData: "Could not fetch Duco ventilation temperatures", exc_info=err ) + bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget] = {} + if self._supports_bypass_supply_temperature_targets: + try: + bypass_supply_temperature_targets = ( + await self.client.async_get_bypass_supply_temperature_targets() + ) + except DucoUnsupportedCapabilityError: + bypass_supply_temperature_targets = {} + self._supports_bypass_supply_temperature_targets = False + except DucoConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except DucoError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="api_error", + ) from err + return DucoData( nodes={node.node_id: node for node in nodes}, node_actions=node_actions, rssi_wifi=rssi_wifi, time_filter_remain=time_filter_remain, ventilation_temperatures=ventilation_temperatures, + bypass_supply_temperature_targets=bypass_supply_temperature_targets, ) diff --git a/homeassistant/components/duco/number.py b/homeassistant/components/duco/number.py new file mode 100644 index 00000000000000..c513aabd1a6a0c --- /dev/null +++ b/homeassistant/components/duco/number.py @@ -0,0 +1,188 @@ +"""Number platform for the Duco integration.""" + +from decimal import ROUND_DOWN, ROUND_HALF_UP, Decimal +import logging +from typing import override + +from duco_connectivity import DucoError, DucoRateLimitError +from duco_connectivity.models import Node + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import BOX_NODE_ID, DOMAIN +from .coordinator import DucoConfigEntry, DucoCoordinator +from .entity import DucoEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 + + +NUMBER_DESCRIPTIONS: tuple[NumberEntityDescription, ...] = ( + NumberEntityDescription( + key="bypass_supply_target_temperature_zone", + translation_key="bypass_supply_target_temperature_zone", + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: DucoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Duco number entities.""" + coordinator = entry.runtime_data + known_entities: set[tuple[str, int]] = set() + + @callback + def _async_add_new_entities() -> None: + """Add number entities for discovered bypass temperature targets.""" + new_entities = [] + targets = coordinator.data.bypass_supply_temperature_targets + for description in NUMBER_DESCRIPTIONS: + for zone_id, target in targets.items(): + if (description.key, zone_id) in known_entities: + continue + + # Skip incomplete metadata because guessing valid limits would expose an invalid control. + if ( + target.minimum is None + or target.maximum is None + or target.increment is None + ): + continue + + known_entities.add((description.key, zone_id)) + new_entities.append( + DucoBypassSupplyTemperatureTargetNumber( + coordinator, + coordinator.data.nodes[BOX_NODE_ID], + description, + zone_id, + target.minimum, + target.maximum, + target.increment, + ) + ) + + if new_entities: + async_add_entities(new_entities) + + entry.async_on_unload(coordinator.async_add_listener(_async_add_new_entities)) + _async_add_new_entities() + + +class DucoBypassSupplyTemperatureTargetNumber(DucoEntity, NumberEntity): + """Number entity for a zone's bypass supply temperature target.""" + + def __init__( + self, + coordinator: DucoCoordinator, + node: Node, + description: NumberEntityDescription, + zone_id: int, + minimum: float, + maximum: float, + increment: float, + ) -> None: + """Initialize the bypass supply temperature target number.""" + super().__init__(coordinator, node) + self.entity_description = description + self._zone_id = zone_id + self._attr_translation_placeholders = {"zone": str(zone_id)} + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}_{node.node_id}_" + f"{description.key}_{zone_id}" + ) + # Duco reports these as capability bounds for the target control rather + # than live state, so the number entity keeps them fixed after creation. + self._attr_native_min_value = minimum + self._attr_native_max_value = maximum + self._attr_native_step = increment + + @property + @override + def available(self) -> bool: + """Return True if the zone currently exposes a bypass target.""" + return ( + super().available + and self._zone_id in self.coordinator.data.bypass_supply_temperature_targets + ) + + @property + @override + def native_value(self) -> float | None: + """Return the current bypass supply temperature target.""" + target = self.coordinator.data.bypass_supply_temperature_targets.get( + self._zone_id + ) + return target.value if target else None + + def _normalize_step_value(self, value: float) -> float: + """Normalize converted temperature values to the nearest supported native step.""" + if self.unit_of_measurement == self.native_unit_of_measurement: + return value + + # Home Assistant converts service values from the configured temperature + # unit first, which can land between valid Duco Celsius increments. + minimum = Decimal(str(self.native_min_value)) + step = Decimal(str(self.native_step)) + steps = ((Decimal(str(value)) - minimum) / step).to_integral_value( + rounding=ROUND_HALF_UP + ) + # Rounding up may overshoot when the range is not a whole number of steps. + max_steps = ( + (Decimal(str(self.native_max_value)) - minimum) / step + ).to_integral_value(rounding=ROUND_DOWN) + return float(minimum + (min(steps, max_steps) * step)) + + @override + async def async_set_native_value(self, value: float) -> None: + """Set the bypass supply temperature target.""" + value = self._normalize_step_value(value) + if ( + (Decimal(str(value)) - Decimal(str(self.native_min_value))) + / Decimal(str(self.native_step)) + ) % 1 != 0: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_bypass_supply_temperature_target_step", + translation_placeholders={ + "value": str(value), + "minimum": str(self.native_min_value), + "increment": str(self.native_step), + }, + ) + + try: + await self.coordinator.client.async_set_bypass_supply_temperature_target( + self._zone_id, value + ) + except DucoRateLimitError as err: + _LOGGER.warning( + "Duco write rate limit exceeded for bypass target zone %s", + self._zone_id, + ) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="rate_limit_exceeded", + ) from err + except DucoError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_set_bypass_supply_temperature_target", + ) from err + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/duco/strings.json b/homeassistant/components/duco/strings.json index 4f5eb782f93ac4..85c0130db08377 100644 --- a/homeassistant/components/duco/strings.json +++ b/homeassistant/components/duco/strings.json @@ -48,6 +48,11 @@ } } }, + "number": { + "bypass_supply_target_temperature_zone": { + "name": "Bypass target {zone}" + } + }, "select": { "ventilation_state": { "name": "Ventilation state", @@ -134,9 +139,15 @@ "connection_error": { "message": "Could not connect to the Duco device." }, + "failed_to_set_bypass_supply_temperature_target": { + "message": "Failed to set bypass supply target temperature." + }, "failed_to_set_state": { "message": "Failed to set ventilation state." }, + "invalid_bypass_supply_temperature_target_step": { + "message": "The value {value} does not match the supported increment of {increment} starting at {minimum}." + }, "rate_limit_exceeded": { "message": "The Duco device has reached its daily write limit. Try again tomorrow." }, diff --git a/homeassistant/components/elgato/__init__.py b/homeassistant/components/elgato/__init__.py index c11f70654345c1..310bd3a9752c1e 100644 --- a/homeassistant/components/elgato/__init__.py +++ b/homeassistant/components/elgato/__init__.py @@ -10,7 +10,14 @@ from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -PLATFORMS = [Platform.BUTTON, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BUTTON, + Platform.LIGHT, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/elgato/helpers.py b/homeassistant/components/elgato/helpers.py index d512be0dba6798..12753e76212d7b 100644 --- a/homeassistant/components/elgato/helpers.py +++ b/homeassistant/components/elgato/helpers.py @@ -8,8 +8,31 @@ from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN +from .coordinator import ElgatoData from .entity import ElgatoEntity +# Elgato lights that can do color reach less far at either end. +COLOR_TEMPERATURE_RANGE = (2900, 6993) # 344 - 143 mireds +COLOR_TEMPERATURE_RANGE_COLOR = (3500, 6500) # 285 - 153 mireds + +COLOR_CAPABLE_PRODUCTS = ("Elgato Light Strip", "Elgato Light Strip Pro") + + +def supports_color(data: ElgatoData) -> bool: + """Return if an Elgato Light does more than white.""" + return bool( + data.info.product_name in COLOR_CAPABLE_PRODUCTS + or data.settings.power_on_hue + or data.state.hue is not None + ) + + +def color_temperature_range(data: ElgatoData) -> tuple[int, int]: + """Return the color temperature range in Kelvin a device supports.""" + if supports_color(data): + return COLOR_TEMPERATURE_RANGE_COLOR + return COLOR_TEMPERATURE_RANGE + def elgato_exception_handler[_ElgatoEntityT: ElgatoEntity, **_P]( func: Callable[Concatenate[_ElgatoEntityT, _P], Coroutine[Any, Any, Any]], diff --git a/homeassistant/components/elgato/icons.json b/homeassistant/components/elgato/icons.json index d2c286594c7b12..628bd8c207727a 100644 --- a/homeassistant/components/elgato/icons.json +++ b/homeassistant/components/elgato/icons.json @@ -1,5 +1,23 @@ { "entity": { + "number": { + "power_on_brightness": { + "default": "mdi:brightness-percent" + }, + "power_on_temperature": { + "default": "mdi:thermometer" + } + }, + "select": { + "power_on_behavior": { + "default": "mdi:power-settings" + } + }, + "sensor": { + "wifi_signal_strength": { + "default": "mdi:wifi" + } + }, "switch": { "bypass": { "default": "mdi:battery-off-outline" diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index 3d5ba506df967b..d223a24b6844ba 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -15,7 +15,7 @@ from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -from .helpers import elgato_exception_handler +from .helpers import color_temperature_range, elgato_exception_handler, supports_color PARALLEL_UPDATES = 1 @@ -34,8 +34,6 @@ class ElgatoLight(ElgatoEntity, LightEntity): """Defines an Elgato Light.""" _attr_name = None - _attr_min_color_temp_kelvin = 2900 # 344 Mireds - _attr_max_color_temp_kelvin = 6993 # 143 Mireds def __init__(self, coordinator: ElgatoDataUpdateCoordinator) -> None: """Initialize Elgato Light.""" @@ -43,19 +41,13 @@ def __init__(self, coordinator: ElgatoDataUpdateCoordinator) -> None: self._attr_supported_color_modes = {ColorMode.COLOR_TEMP} self._attr_unique_id = coordinator.data.info.serial_number - # Elgato Light supporting color, have a different temperature range - if ( - self.coordinator.data.info.product_name - in ( - "Elgato Light Strip", - "Elgato Light Strip Pro", - ) - or self.coordinator.data.settings.power_on_hue - or self.coordinator.data.state.hue is not None - ): + if supports_color(coordinator.data): self._attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS} - self._attr_min_color_temp_kelvin = 3500 # 285 Mireds - self._attr_max_color_temp_kelvin = 6500 # 153 Mireds + + ( + self._attr_min_color_temp_kelvin, + self._attr_max_color_temp_kelvin, + ) = color_temperature_range(coordinator.data) @property @override diff --git a/homeassistant/components/elgato/manifest.json b/homeassistant/components/elgato/manifest.json index 1c63ef0d5f4b80..71775228f167c1 100644 --- a/homeassistant/components/elgato/manifest.json +++ b/homeassistant/components/elgato/manifest.json @@ -12,6 +12,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["elgato==6.0.0"], + "requirements": ["elgato==6.1.0"], "zeroconf": ["_elg._tcp.local."] } diff --git a/homeassistant/components/elgato/number.py b/homeassistant/components/elgato/number.py new file mode 100644 index 00000000000000..f6eebb4667f564 --- /dev/null +++ b/homeassistant/components/elgato/number.py @@ -0,0 +1,130 @@ +"""Support for Elgato numbers.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, override + +from elgato import Elgato + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.color import ( + color_temperature_kelvin_to_mired, + color_temperature_mired_to_kelvin, +) + +from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator +from .entity import ElgatoEntity +from .helpers import color_temperature_range, elgato_exception_handler + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class ElgatoNumberEntityDescription(NumberEntityDescription): + """Class describing Elgato number entities.""" + + has_fn: Callable[[ElgatoData], bool] = lambda _: True + range_fn: Callable[[ElgatoData], tuple[int, int]] | None = None + value_fn: Callable[[ElgatoData], float | None] + set_fn: Callable[[Elgato, float], Awaitable[Any]] + + +NUMBERS = [ + ElgatoNumberEntityDescription( + key="power_on_brightness", + translation_key="power_on_brightness", + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, + native_min_value=0, + native_max_value=100, + native_step=1, + value_fn=lambda x: x.settings.power_on_brightness, + set_fn=lambda client, value: client.power_on_behavior(brightness=int(value)), + ), + ElgatoNumberEntityDescription( + key="power_on_temperature", + translation_key="power_on_temperature", + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTemperature.KELVIN, + # Narrows on a device that does color, exactly as the light does. + range_fn=color_temperature_range, + native_step=50, + has_fn=lambda x: x.settings.power_on_temperature is not None, + # A light set to power on to a color reports a zero, which is not a + # color temperature. The setting can be changed back, so the entity + # stays and goes unknown rather than disappearing. + value_fn=lambda x: ( + color_temperature_mired_to_kelvin(x.settings.power_on_temperature) + if x.settings.power_on_temperature + else None + ), + set_fn=lambda client, value: client.power_on_behavior( + temperature=color_temperature_kelvin_to_mired(value) + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ElgatoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Elgato numbers based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + ElgatoNumberEntity( + coordinator=coordinator, + description=description, + ) + for description in NUMBERS + if description.has_fn(coordinator.data) + ) + + +class ElgatoNumberEntity(ElgatoEntity, NumberEntity): + """Representation of an Elgato number.""" + + entity_description: ElgatoNumberEntityDescription + + def __init__( + self, + coordinator: ElgatoDataUpdateCoordinator, + description: ElgatoNumberEntityDescription, + ) -> None: + """Initiate Elgato number.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.data.info.serial_number}_{description.key}" + ) + + if description.range_fn is not None: + ( + self._attr_native_min_value, + self._attr_native_max_value, + ) = description.range_fn(coordinator.data) + + @property + @override + def native_value(self) -> float | None: + """Return the number value.""" + if (value := self.entity_description.value_fn(self.coordinator.data)) is None: + return None + + # A Kelvin value that survives the trip out does not always survive + # the trip back. Setting 6500 K stores 153 mireds, which reads as + # 6535 K, above a maximum that cannot then be set again. + return min(max(value, self.native_min_value), self.native_max_value) + + @elgato_exception_handler + @override + async def async_set_native_value(self, value: float) -> None: + """Change the number value.""" + await self.entity_description.set_fn(self.coordinator.client, value) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/elgato/select.py b/homeassistant/components/elgato/select.py new file mode 100644 index 00000000000000..fc7e7b0cf8d3cf --- /dev/null +++ b/homeassistant/components/elgato/select.py @@ -0,0 +1,100 @@ +"""Support for Elgato select entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, override + +from elgato import Elgato, PowerOnBehavior + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator +from .entity import ElgatoEntity +from .helpers import elgato_exception_handler + +PARALLEL_UPDATES = 1 + +# The device also has a value 0, which it reports when it has no opinion yet. +# It cannot be selected, so it is not an option, and it leaves the entity +# unknown until the behavior is actually set. +POWER_ON_BEHAVIORS = { + PowerOnBehavior.RESTORE_LAST: "restore_last", + PowerOnBehavior.USE_DEFAULTS: "use_defaults", +} +POWER_ON_BEHAVIOR_OPTIONS = {value: key for key, value in POWER_ON_BEHAVIORS.items()} + + +@dataclass(frozen=True, kw_only=True) +class ElgatoSelectEntityDescription(SelectEntityDescription): + """Class describing Elgato select entities.""" + + has_fn: Callable[[ElgatoData], bool] = lambda _: True + current_fn: Callable[[ElgatoData], str | None] + select_fn: Callable[[Elgato, str], Awaitable[Any]] + + +SELECTS = [ + ElgatoSelectEntityDescription( + key="power_on_behavior", + translation_key="power_on_behavior", + entity_category=EntityCategory.CONFIG, + options=list(POWER_ON_BEHAVIORS.values()), + current_fn=lambda x: POWER_ON_BEHAVIORS.get(x.settings.power_on_behavior), + select_fn=lambda client, option: client.power_on_behavior( + behavior=POWER_ON_BEHAVIOR_OPTIONS[option] + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ElgatoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Elgato select entities based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + ElgatoSelectEntity( + coordinator=coordinator, + description=description, + ) + for description in SELECTS + if description.has_fn(coordinator.data) + ) + + +class ElgatoSelectEntity(ElgatoEntity, SelectEntity): + """Representation of an Elgato select entity.""" + + entity_description: ElgatoSelectEntityDescription + + def __init__( + self, + coordinator: ElgatoDataUpdateCoordinator, + description: ElgatoSelectEntityDescription, + ) -> None: + """Initiate Elgato select entity.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.data.info.serial_number}_{description.key}" + ) + + @property + @override + def current_option(self) -> str | None: + """Return the selected option.""" + return self.entity_description.current_fn(self.coordinator.data) + + @elgato_exception_handler + @override + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.select_fn(self.coordinator.client, option) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/elgato/sensor.py b/homeassistant/components/elgato/sensor.py index d8c9a1406fb89f..50b6372bd773fb 100644 --- a/homeassistant/components/elgato/sensor.py +++ b/homeassistant/components/elgato/sensor.py @@ -12,6 +12,7 @@ ) from homeassistant.const import ( PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -97,6 +98,26 @@ class ElgatoSensorEntityDescription(SensorEntityDescription): has_fn=lambda x: x.battery is not None, value_fn=lambda x: x.battery.input_charge_voltage if x.battery else None, ), + ElgatoSensorEntityDescription( + key="wifi_signal_strength", + translation_key="wifi_signal_strength", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + has_fn=lambda x: x.info.wifi is not None, + value_fn=lambda x: x.info.wifi.signal_strength if x.info.wifi else None, + ), + ElgatoSensorEntityDescription( + key="wifi_rssi", + translation_key="wifi_rssi", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + has_fn=lambda x: x.info.wifi is not None, + value_fn=lambda x: x.info.wifi.rssi if x.info.wifi else None, + ), ] diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index dcfeb23d9acc9f..6f813761f5440f 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -36,6 +36,23 @@ } }, "entity": { + "number": { + "power_on_brightness": { + "name": "Power-on brightness" + }, + "power_on_temperature": { + "name": "Power-on color temperature" + } + }, + "select": { + "power_on_behavior": { + "name": "Power-on behavior", + "state": { + "restore_last": "Restore last state", + "use_defaults": "Use defaults" + } + } + }, "sensor": { "charge_power": { "name": "Charging power" @@ -48,6 +65,12 @@ }, "voltage": { "name": "Battery voltage" + }, + "wifi_rssi": { + "name": "Wi-Fi RSSI" + }, + "wifi_signal_strength": { + "name": "Wi-Fi signal strength" } }, "switch": { diff --git a/homeassistant/components/frontier_silicon/__init__.py b/homeassistant/components/frontier_silicon/__init__.py index 45db1d44f2ac99..19878094edb000 100644 --- a/homeassistant/components/frontier_silicon/__init__.py +++ b/homeassistant/components/frontier_silicon/__init__.py @@ -11,7 +11,7 @@ from .const import CONF_WEBFSAPI_URL -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index fe18bb9264614f..642360028760c0 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -34,6 +34,13 @@ } } }, + "entity": { + "switch": { + "dst": { + "name": "Daylight Saving Time" + } + } + }, "exceptions": { "api_error": { "message": "Failed to execute {command}: {message}" diff --git a/homeassistant/components/frontier_silicon/switch.py b/homeassistant/components/frontier_silicon/switch.py new file mode 100644 index 00000000000000..85351bd6743bd9 --- /dev/null +++ b/homeassistant/components/frontier_silicon/switch.py @@ -0,0 +1,115 @@ +"""Support for switches on Frontier Silicon Devices (Medion, Hama, Auna,...).""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from functools import partial +import logging +from typing import Any, override + +from afsapi import AFSAPI, FSConnectionError, FSNotImplementedError + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FrontierSiliconConfigEntry +from .entity import FrontierSiliconEntity, fs_command_exception_wrap + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class AFSAPISwitchEntityDescription(SwitchEntityDescription): + """Describes Frontier Silicon switch entity.""" + + is_on_fn: Callable[[AFSAPI], Callable[[], Coroutine[Any, Any, bool]]] + turn_on_fn: Callable[[AFSAPI], Callable[[], Coroutine[Any, Any, None]]] + turn_off_fn: Callable[[AFSAPI], Callable[[], Coroutine[Any, Any, None]]] + + +SWITCHES: tuple[AFSAPISwitchEntityDescription, ...] = ( + AFSAPISwitchEntityDescription( + key="dst", + entity_category=EntityCategory.CONFIG, + translation_key="dst", + is_on_fn=lambda afsapi: afsapi.get_dst, + turn_on_fn=lambda afsapi: partial(afsapi.set_dst, True), + turn_off_fn=lambda afsapi: partial(afsapi.set_dst, False), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: FrontierSiliconConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Frontier Silicon entity.""" + + afsapi = config_entry.runtime_data + + # only add switch entities for nodes which exist on the target device + available_switches = [] + max_tries_per_entity = 3 + for description in SWITCHES: + connection_attempt_succeeded = False + num_tries = 0 + while num_tries < max_tries_per_entity: + num_tries += 1 + try: + _ = await description.is_on_fn(afsapi)() + except FSNotImplementedError: + # we connected OK, but the switch is not supported, so stop trying + connection_attempt_succeeded = True + break + except FSConnectionError: + # retry in case the connection error is transient + continue + available_switches.append(description) + connection_attempt_succeeded = True + break + if not connection_attempt_succeeded: + _LOGGER.warning("Could not connect to Frontier Silicon device during setup") + + async_add_entities( + [ + AFSAPISwitch(config_entry, afsapi, description) + for description in available_switches + ], + True, + ) + + +class AFSAPISwitch(FrontierSiliconEntity, SwitchEntity): + """Representation of a switch on a Frontier Silicon device.""" + + entity_description: AFSAPISwitchEntityDescription + + def __init__( + self, + config_entry: FrontierSiliconConfigEntry, + afsapi: AFSAPI, + description: AFSAPISwitchEntityDescription, + ) -> None: + """Initialize the Frontier Silicon API device.""" + super().__init__(afsapi, config_entry) + self.entity_description = description + self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" + + @fs_command_exception_wrap + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + await self.entity_description.turn_off_fn(self.fs_device)() + + @fs_command_exception_wrap + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + await self.entity_description.turn_on_fn(self.fs_device)() + + @override + async def _fs_update(self) -> None: + """Update Frontier Silicon entity.""" + self._attr_is_on = await self.entity_description.is_on_fn(self.fs_device)() diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 5b8cc556fbee84..05a6ab14456e74 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==2.15.0"] + "requirements": ["homematicip==2.16.0"] } diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py index b450b7adc68bae..7699b069379089 100644 --- a/homeassistant/components/icloud/__init__.py +++ b/homeassistant/components/icloud/__init__.py @@ -18,6 +18,7 @@ STORAGE_KEY, STORAGE_VERSION, ) +from .coordinator import IcloudCalendarCoordinator from .media_source import async_setup_mediasource, async_setup_photo_cache from .services import async_setup_services @@ -62,6 +63,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo await hass.async_add_executor_job(account.setup) + # Refreshed before the platforms are forwarded so the calendars are known + # by the time the calendar platform sets up. This deliberately does not use + # async_config_entry_first_refresh: an account that fails to authenticate + # still loads and starts a reauth flow, and a calendar outage should not + # take device tracking down with it. Calendars that are missing from the + # first refresh appear on a later one through the coordinator listener. + account.calendar_coordinator = IcloudCalendarCoordinator(hass, entry) + await account.calendar_coordinator.async_refresh() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await async_setup_photo_cache(hass, account) diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index 8c04d071d40205..a760e6c32e3c59 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -56,6 +56,7 @@ ) if TYPE_CHECKING: + from .coordinator import IcloudCalendarCoordinator from .media_source import PhotoCache _LOGGER = logging.getLogger(__name__) @@ -98,6 +99,9 @@ def __init__( self._unsub_fetch: CALLBACK_TYPE | None = None self.listeners: list[CALLBACK_TYPE] = [] + # Built in async_setup_entry, before the platforms are forwarded. + self.calendar_coordinator: IcloudCalendarCoordinator | None = None + self.photo_cache: PhotoCache | None = None def setup(self) -> None: diff --git a/homeassistant/components/icloud/calendar.py b/homeassistant/components/icloud/calendar.py new file mode 100644 index 00000000000000..7b756418016613 --- /dev/null +++ b/homeassistant/components/icloud/calendar.py @@ -0,0 +1,120 @@ +"""Support for iCloud Calendars.""" + +from datetime import datetime +from typing import override + +from pyicloud.exceptions import PyiCloudException + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util + +from .account import IcloudConfigEntry +from .const import DOMAIN +from .coordinator import IcloudCalendarCoordinator, IcloudCalendarData, localize + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IcloudConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the iCloud calendars.""" + coordinator = entry.runtime_data.calendar_coordinator + assert coordinator is not None + + known: set[str] = set() + + @callback + def _add_new_calendars() -> None: + """Add entities for calendars that appeared since the last poll.""" + if not (new := set(coordinator.data or {}) - known): + return + known.update(new) + async_add_entities( + IcloudCalendarEntity(coordinator, entry, guid) for guid in new + ) + + _add_new_calendars() + entry.async_on_unload(coordinator.async_add_listener(_add_new_calendars)) + + +class IcloudCalendarEntity( + CoordinatorEntity[IcloudCalendarCoordinator], CalendarEntity +): + """A calendar from iCloud.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: IcloudCalendarCoordinator, + entry: IcloudConfigEntry, + guid: str, + ) -> None: + """Initialize the calendar.""" + super().__init__(coordinator) + self._guid = guid + self._attr_unique_id = f"{entry.unique_id}_{guid}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry.unique_id}_account")}, + manufacturer="Apple", + name=entry.title, + entry_type=DeviceEntryType.SERVICE, + ) + + @property + def _calendar(self) -> IcloudCalendarData | None: + """Return the cached calendar, or None once it is gone from iCloud.""" + return self.coordinator.data.get(self._guid) + + @property + @override + def available(self) -> bool: + """Return True if the calendar still exists in iCloud.""" + return super().available and self._calendar is not None + + @property + @override + def name(self) -> str | None: + """Return the name of the calendar.""" + if (calendar := self._calendar) is not None: + return calendar.name + return None + + @property + @override + def event(self) -> CalendarEvent | None: + """Return the event in progress, or the next one to start.""" + if (calendar := self._calendar) is None: + return None + + now = dt_util.now() + upcoming: CalendarEvent | None = None + for event in calendar.events: + if localize(event.end) <= now: + continue + if localize(event.start) <= now: + return event + if upcoming is None or localize(event.start) < localize(upcoming.start): + upcoming = event + + return upcoming + + @override + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Return the events in an arbitrary range.""" + try: + events = await hass.async_add_executor_job( + self.coordinator.fetch_events, start_date, end_date, [self._guid] + ) + except PyiCloudException as err: + raise HomeAssistantError(f"Error fetching events: {err}") from err + + return events.get(self._guid, []) diff --git a/homeassistant/components/icloud/const.py b/homeassistant/components/icloud/const.py index 72b1d496121b61..f651b41b258fa2 100644 --- a/homeassistant/components/icloud/const.py +++ b/homeassistant/components/icloud/const.py @@ -18,7 +18,7 @@ STORAGE_KEY = DOMAIN STORAGE_VERSION = 2 -PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] +PLATFORMS = [Platform.CALENDAR, Platform.DEVICE_TRACKER, Platform.SENSOR] # pyicloud.AppleDevice status DEVICE_BATTERY_LEVEL = "batteryLevel" diff --git a/homeassistant/components/icloud/coordinator.py b/homeassistant/components/icloud/coordinator.py new file mode 100644 index 00000000000000..1f4d11dff3ae37 --- /dev/null +++ b/homeassistant/components/icloud/coordinator.py @@ -0,0 +1,198 @@ +"""Coordinator for iCloud Calendars.""" + +from dataclasses import dataclass +from datetime import date, datetime, timedelta, tzinfo +import logging +from typing import override + +from pyicloud.exceptions import PyiCloudException +from pyicloud.services.calendar import CalendarService, EventObject + +from homeassistant.components.calendar import CalendarEvent +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .account import IcloudConfigEntry +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(minutes=15) + +# How much of the calendar to keep cached for the entity's current/next event. +# `async_get_events` queries iCloud directly for anything outside this window. +LOOKBACK = timedelta(days=1) +LOOKAHEAD = timedelta(days=30) + + +@dataclass(slots=True) +class IcloudCalendarData: + """A calendar and the events cached for it.""" + + name: str + events: list[CalendarEvent] + + +def localize(value: date | datetime) -> datetime: + """Return a comparable, timezone-aware datetime for a date or datetime.""" + if isinstance(value, datetime): + return dt_util.as_local(value) + return dt_util.start_of_local_day(value) + + +class IcloudCalendarCoordinator(DataUpdateCoordinator[dict[str, IcloudCalendarData]]): + """Keep a rolling window of events cached for the current/next lookup.""" + + def __init__(self, hass: HomeAssistant, entry: IcloudConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.account = entry.runtime_data + + @property + def _calendars(self) -> CalendarService: + """Return the calendar service of the authenticated account.""" + if (api := self.account.api) is None: + raise ConfigEntryAuthFailed("iCloud account is not authenticated") + return api.calendar + + def fetch_events( + self, start: datetime, end: datetime, guids: list[str] | None = None + ) -> dict[str, list[CalendarEvent]]: + """Return events per calendar between two points. Runs in the executor.""" + service = self._calendars + + # An explicit empty list means no calendars, not every calendar. + if guids is not None and not guids: + return {} + + wanted = set(guids) if guids is not None else None + result: dict[str, list[CalendarEvent]] = {guid: [] for guid in (wanted or ())} + + # pyicloud sends both bounds as plain dates, so iCloud answers with the + # whole of each boundary day whatever times were asked for. Keep only + # the events that really overlap the window. + window_start = localize(start) + window_end = localize(end) + + for event in service.get_events(from_dt=start, to_dt=end, as_objs=True): + if wanted is not None and event.pguid not in wanted: + continue + if (parsed := _as_calendar_event(event)) is None: + continue + if ( + localize(parsed.start) >= window_end + or localize(parsed.end) <= window_start + ): + continue + result.setdefault(event.pguid, []).append(parsed) + + for events in result.values(): + events.sort(key=lambda event: localize(event.start)) + return result + + def _fetch(self) -> dict[str, IcloudCalendarData]: + """Fetch the calendars and their events. Runs in the executor.""" + names = { + calendar.guid: calendar.title + for calendar in self._calendars.get_calendars(as_objs=True) + } + now = dt_util.now() + events = self.fetch_events(now - LOOKBACK, now + LOOKAHEAD, list(names)) + + return { + guid: IcloudCalendarData(name=name, events=events.get(guid, [])) + for guid, name in names.items() + } + + @override + async def _async_update_data(self) -> dict[str, IcloudCalendarData]: + """Fetch calendars and their upcoming events.""" + try: + return await self.hass.async_add_executor_job(self._fetch) + except PyiCloudException as err: + raise UpdateFailed(f"Error fetching calendars: {err}") from err + + +def _parse_apple_date(value: datetime | list[int] | None) -> datetime | None: + """Parse the date format iCloud returns for calendar events. + + ``EventObject`` is annotated as holding ``datetime``, but pyicloud hands + back the wire format unchanged: ``[yyyymmdd, year, month, day, hour, + minute, minutes_since_midnight]``. Both forms are accepted so this keeps + working if that is ever changed upstream. + """ + if value is None: + return None + if isinstance(value, datetime): + return value + if len(value) >= 6: + try: + _, year, month, day, hour, minute = value[:6] + return datetime(int(year), int(month), int(day), int(hour), int(minute)) + except TypeError, ValueError: + _LOGGER.debug("Unparsable calendar date: %r", value) + return None + + +def _event_timezone(event: EventObject) -> tzinfo: + """Return the timezone an event's wall-clock times are expressed in. + + iCloud reports naive local times alongside a `tz` field. "Floating" means + the event has no zone of its own and should follow the viewer, so fall + back to Home Assistant's timezone in that case. + """ + if (name := event.tz) and name != "Floating": + try: + if (zone := dt_util.get_time_zone(name)) is not None: + return zone + except ValueError: + # get_time_zone rejects malformed keys rather than returning None. + _LOGGER.debug("Unknown calendar event timezone: %r", name) + return dt_util.get_default_time_zone() + + +def _as_calendar_event(event: EventObject) -> CalendarEvent | None: + """Convert a pyicloud event into a Home Assistant calendar event.""" + start = _parse_apple_date(event.local_start_date) or _parse_apple_date( + event.start_date + ) + if start is None: + return None + + end = _parse_apple_date(event.local_end_date) or _parse_apple_date(event.end_date) + if end is None: + end = start + timedelta(hours=1) + + start_value: date | datetime + end_value: date | datetime + if event.all_day: + start_value = start.date() + end_value = end.date() + # Home Assistant treats the end of an all-day event as exclusive. + if end_value <= start_value: + end_value = start_value + timedelta(days=1) + else: + # The wire format is a naive wall-clock time; only a `datetime` from a + # future pyicloud can already carry a zone, and replacing it would + # move the event to a different instant. + zone = _event_timezone(event) + start_value = start if start.tzinfo else start.replace(tzinfo=zone) + end_value = end if end.tzinfo else end.replace(tzinfo=zone) + if end_value <= start_value: + end_value = start_value + timedelta(minutes=30) + + return CalendarEvent( + uid=event.guid or None, + summary=event.title or "", + start=start_value, + end=end_value, + location=event.location or None, + ) diff --git a/homeassistant/components/kaco_modbus/__init__.py b/homeassistant/components/kaco_modbus/__init__.py new file mode 100644 index 00000000000000..cf7b1c01097ed0 --- /dev/null +++ b/homeassistant/components/kaco_modbus/__init__.py @@ -0,0 +1,37 @@ +"""The KACO Modbus integration.""" + +from kaco_modbus import KacoInverter +from modbus_connection import ModbusTcpParams + +from homeassistant.components.modbus import async_get_unit +from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.core import HomeAssistant + +from .const import CONF_UNIT_ID +from .coordinator import KacoConfigEntry, KacoDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: KacoConfigEntry) -> bool: + """Set up KACO Modbus from a config entry.""" + # Shared with any other integration on this gateway, and closed when the + # last entry holding a unit on it unloads. + unit = async_get_unit( + hass, + entry, + ModbusTcpParams(host=entry.data[CONF_HOST], port=entry.data[CONF_PORT]), + entry.data[CONF_UNIT_ID], + ) + + coordinator = KacoDataUpdateCoordinator(hass, entry, KacoInverter(unit)) + 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: KacoConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/kaco_modbus/config_flow.py b/homeassistant/components/kaco_modbus/config_flow.py new file mode 100644 index 00000000000000..99cfea8153216c --- /dev/null +++ b/homeassistant/components/kaco_modbus/config_flow.py @@ -0,0 +1,87 @@ +"""Adding an inverter by address.""" + +import logging +from typing import Any, override + +from kaco_modbus import KacoError, KacoInverter, NotAKacoInverterError +from modbus_connection import ModbusError, ModbusTcpParams +import voluptuous as vol + +from homeassistant.components.modbus import async_get_temporary_unit +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + TextSelector, +) + +from .const import CONF_UNIT_ID, DEFAULT_PORT, DEFAULT_UNIT_ID, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): TextSelector(), + vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.All( + NumberSelector( + NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1, max=65535) + ), + vol.Coerce(int), + ), + vol.Required(CONF_UNIT_ID, default=DEFAULT_UNIT_ID): vol.All( + NumberSelector( + NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1, max=247) + ), + vol.Coerce(int), + ), + } +) + + +class KacoModbusConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for KACO Modbus.""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask for an address and check a KACO inverter answers there.""" + errors: dict[str, str] = {} + + if user_input is not None: + params = ModbusTcpParams( + host=user_input[CONF_HOST], port=user_input[CONF_PORT] + ) + try: + async with async_get_temporary_unit( + self.hass, params, user_input[CONF_UNIT_ID] + ) as unit: + device = KacoInverter(unit) + await device.async_update_readings() + except NotAKacoInverterError: + errors["base"] = "not_a_kaco_inverter" + except KacoError: + errors["base"] = "not_a_sunspec_inverter" + except ModbusError, HomeAssistantError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + info = device.info + assert info is not None + # Stable across address changes, which a host or port is not. + await self.async_set_unique_id(info.serial_number) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info.model, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/kaco_modbus/const.py b/homeassistant/components/kaco_modbus/const.py new file mode 100644 index 00000000000000..8a7b37a18b4fe9 --- /dev/null +++ b/homeassistant/components/kaco_modbus/const.py @@ -0,0 +1,9 @@ +"""Constants for the KACO Modbus integration.""" + +DOMAIN = "kaco_modbus" + +CONF_UNIT_ID = "unit_id" + +DEFAULT_PORT = 502 +# Ignored by TCP-native inverters, but not by one behind an RS485 gateway. +DEFAULT_UNIT_ID = 1 diff --git a/homeassistant/components/kaco_modbus/coordinator.py b/homeassistant/components/kaco_modbus/coordinator.py new file mode 100644 index 00000000000000..9bab7f48204226 --- /dev/null +++ b/homeassistant/components/kaco_modbus/coordinator.py @@ -0,0 +1,103 @@ +"""Polling, and what to do when an inverter stops answering.""" + +from datetime import timedelta +import logging +from typing import override + +from kaco_modbus import ( + KacoInverter, + NotAKacoInverterError, + SunSpecMapShiftError, + UpdateReport, +) +from modbus_connection import ModbusError +from propcache.api import cached_property + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=30) + +type KacoConfigEntry = ConfigEntry[KacoDataUpdateCoordinator] + + +class KacoDataUpdateCoordinator(DataUpdateCoordinator[UpdateReport]): + """Poll the inverter's readings, and report what actually came back.""" + + config_entry: KacoConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: KacoConfigEntry, + device: KacoInverter, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=entry.title, + update_interval=SCAN_INTERVAL, + ) + self.device = device + + @cached_property + def device_info(self) -> DeviceInfo: + """The one inverter every entity on this config entry belongs to.""" + info = self.device.info + assert info is not None + return DeviceInfo( + identifiers={(DOMAIN, info.serial_number)}, + manufacturer=info.manufacturer, + model=info.model, + sw_version=info.firmware, + serial_number=info.serial_number, + ) + + @override + async def _async_update_data(self) -> UpdateReport: + try: + report = await self.device.async_update_readings() + except NotAKacoInverterError as err: + # Identity is settled on the first poll, so a swapped device + # surfaces here. Retrying cannot make it a KACO. + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="not_a_kaco_inverter", + translation_placeholders={"error": str(err)}, + ) from err + except SunSpecMapShiftError as err: + # Every bound register offset is stale; only rediscovery fixes it. + self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="sunspec_map_moved", + translation_placeholders={"error": str(err)}, + ) from err + except ModbusError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": str(err)}, + ) from err + + if not report.updated: + # A KACO after dark accepts the connection and answers nothing. + # The library records that per component rather than raising it. + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="no_component_answered", + translation_placeholders={"name": self.name}, + ) from ExceptionGroup( + "every component failed to refresh", list(report.failed.values()) + ) + + return report diff --git a/homeassistant/components/kaco_modbus/entity.py b/homeassistant/components/kaco_modbus/entity.py new file mode 100644 index 00000000000000..1b2035ffc36d0c --- /dev/null +++ b/homeassistant/components/kaco_modbus/entity.py @@ -0,0 +1,50 @@ +"""What every KACO entity shares.""" + +from dataclasses import dataclass +from typing import override + +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import KacoDataUpdateCoordinator + + +@dataclass(frozen=True, kw_only=True) +class KacoEntityDescription(EntityDescription): + """Which component on the inverter an entity reads through.""" + + component: str # attribute name on KacoInverter, e.g. 'inverter' + + +class KacoEntity(CoordinatorEntity[KacoDataUpdateCoordinator]): + """An entity backed by one component of the inverter. + + Components are polled independently, so an entity is unavailable when its + own component failed to read, not when any of them did. + """ + + _attr_has_entity_name = True + entity_description: KacoEntityDescription + + def __init__( + self, + coordinator: KacoDataUpdateCoordinator, + entity_description: KacoEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + + info = coordinator.device.info + assert info is not None + self._attr_unique_id = f"{info.serial_number}_{entity_description.key}" + self._attr_device_info = coordinator.device_info + + @property + @override + def available(self) -> bool: + """Whether this entity's own component answered the last poll.""" + return ( + super().available + and self.entity_description.component in self.coordinator.data.updated + ) diff --git a/homeassistant/components/kaco_modbus/manifest.json b/homeassistant/components/kaco_modbus/manifest.json new file mode 100644 index 00000000000000..e861f2fbb00e6e --- /dev/null +++ b/homeassistant/components/kaco_modbus/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "kaco_modbus", + "name": "KACO Modbus", + "codeowners": ["@g4bri3lDev"], + "config_flow": true, + "dependencies": ["modbus"], + "documentation": "https://www.home-assistant.io/integrations/kaco_modbus", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["kaco-modbus==1.1.0"] +} diff --git a/homeassistant/components/kaco_modbus/quality_scale.yaml b/homeassistant/components/kaco_modbus/quality_scale.yaml new file mode 100644 index 00000000000000..5e4ff32ee2aae4 --- /dev/null +++ b/homeassistant/components/kaco_modbus/quality_scale.yaml @@ -0,0 +1,98 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not register any service 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: This integration does not register any service actions. + docs-conditions: + status: exempt + comment: This integration does not provide any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not provide any triggers. + entity-event-setup: + status: exempt + comment: >- + local_polling; entities rely solely on CoordinatorEntity's built-in + lifecycle, with no manual event subscriptions. + 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: This integration does not register any service actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: >- + Local Modbus TCP; there is no authentication that can expire or be + invalidated. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: There is no discovery to carry updated network information. + discovery: + status: exempt + comment: >- + The inverter advertises no mDNS service, sends no DHCP hostname, and its + MAC OUI is Microchip's rather than KACO's. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: One fixed inverter per config entry; nothing is discovered at runtime. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: >- + All three sensors in this initial platform are primary readings that a + user adds the integration to get. The follow-up PR adding the full + register table is where less popular entities start being disabled. + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: Entities use device_class-derived icons; no custom icons are needed. + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: One fixed inverter per config entry; nothing to detect as stale. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: This integration communicates over Modbus TCP, not HTTP. + strict-typing: done diff --git a/homeassistant/components/kaco_modbus/sensor.py b/homeassistant/components/kaco_modbus/sensor.py new file mode 100644 index 00000000000000..074b7bd2ad0358 --- /dev/null +++ b/homeassistant/components/kaco_modbus/sensor.py @@ -0,0 +1,98 @@ +"""What the inverter measures.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from kaco_modbus.models import InverterThreePhase + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfEnergy, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import KacoConfigEntry +from .entity import KacoEntity, KacoEntityDescription + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class KacoSensorDescription(SensorEntityDescription, KacoEntityDescription): + """A sensor, and where to read its value off the inverter block.""" + + value_fn: Callable[[InverterThreePhase], StateType] + + +SENSOR_DESCRIPTIONS: tuple[KacoSensorDescription, ...] = ( + KacoSensorDescription( + key="ac_power", + component="inverter", + translation_key="ac_power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda inverter: inverter.w, + ), + KacoSensorDescription( + key="lifetime_energy", + component="inverter", + translation_key="lifetime_energy", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + value_fn=lambda inverter: inverter.wh, + ), + KacoSensorDescription( + key="operating_state", + component="inverter", + translation_key="operating_state", + device_class=SensorDeviceClass.ENUM, + options=[ + "off", + "sleeping", + "starting", + "mppt", + "throttled", + "shutting_down", + "fault", + "standby", + ], + value_fn=lambda inverter: ( + None if inverter.st is None else inverter.st.name.lower() + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: KacoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the KACO Modbus sensor platform.""" + async_add_entities( + KacoSensor(entry.runtime_data, description) + for description in SENSOR_DESCRIPTIONS + ) + + +class KacoSensor(KacoEntity, SensorEntity): + """A read-only value off one of the inverter's components.""" + + entity_description: KacoSensorDescription + + @property + @override + def native_value(self) -> StateType: + """Return the value this sensor reads from the device.""" + component = getattr(self.coordinator.device, self.entity_description.component) + return self.entity_description.value_fn(component) diff --git a/homeassistant/components/kaco_modbus/strings.json b/homeassistant/components/kaco_modbus/strings.json new file mode 100644 index 00000000000000..2437b7c4e614f2 --- /dev/null +++ b/homeassistant/components/kaco_modbus/strings.json @@ -0,0 +1,65 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "not_a_kaco_inverter": "That address answers, but the device is not a KACO inverter", + "not_a_sunspec_inverter": "That address answers Modbus, but not as a SunSpec inverter", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "unit_id": "Unit ID" + }, + "data_description": { + "host": "The inverter's hostname or IP address.", + "port": "The Modbus TCP port. KACO inverters use 502 unless changed.", + "unit_id": "Leave at 1 unless the inverter is reached through an RS485-to-TCP gateway, which needs the address configured on that gateway." + }, + "description": "Enter the address of your KACO inverter. Modbus TCP must be enabled on the inverter, under its SunSpec or Modbus protocol settings." + } + } + }, + "entity": { + "sensor": { + "ac_power": { + "name": "AC power" + }, + "lifetime_energy": { + "name": "Total energy produced" + }, + "operating_state": { + "name": "Operating state", + "state": { + "fault": "Fault", + "mppt": "Producing", + "off": "[%key:common::state::off%]", + "shutting_down": "Shutting down", + "sleeping": "Asleep", + "standby": "[%key:common::state::standby%]", + "starting": "Starting up", + "throttled": "Throttled" + } + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "Failed to reach the inverter: {error}" + }, + "no_component_answered": { + "message": "{name} accepted the connection but answered nothing." + }, + "not_a_kaco_inverter": { + "message": "The device at this address is not a KACO inverter: {error}" + }, + "sunspec_map_moved": { + "message": "The inverter's SunSpec register map moved, so the integration is reloading to find it again: {error}" + } + } +} diff --git a/homeassistant/components/led_infrared/button.py b/homeassistant/components/led_infrared/button.py index 2fb449a0aa7433..7ec102e5243f17 100644 --- a/homeassistant/components/led_infrared/button.py +++ b/homeassistant/components/led_infrared/button.py @@ -42,6 +42,14 @@ "quick", "slow", ], + LEDIrDeviceType.GENERIC_10_KEY: [ + "brightness_up", + "brightness_down", + "timer_2h", + "timer_4h", + "timer_6h", + "timer_8h", + ], } diff --git a/homeassistant/components/led_infrared/config_flow.py b/homeassistant/components/led_infrared/config_flow.py index 113c93169969ea..fcedbdd106c6ee 100644 --- a/homeassistant/components/led_infrared/config_flow.py +++ b/homeassistant/components/led_infrared/config_flow.py @@ -28,8 +28,9 @@ ) DEVICE_NAMES = { - LEDIrDeviceType.GENERIC_24_KEY: "24-key remote", + LEDIrDeviceType.GENERIC_10_KEY: "10-key remote", LEDIrDeviceType.GENERIC_13_KEY: "13-key remote", + LEDIrDeviceType.GENERIC_24_KEY: "24-key remote", LEDIrDeviceType.GENERIC_40_KEY: "40-key remote", LEDIrDeviceType.GENERIC_44_KEY: "44-key remote", } diff --git a/homeassistant/components/led_infrared/const.py b/homeassistant/components/led_infrared/const.py index 6a87589b9e64bf..c0e97a99eb6194 100644 --- a/homeassistant/components/led_infrared/const.py +++ b/homeassistant/components/led_infrared/const.py @@ -11,6 +11,7 @@ class LEDIrDeviceType(StrEnum): """LED Infrared device types.""" + GENERIC_10_KEY = "generic_10_key" GENERIC_13_KEY = "generic_13_key" GENERIC_24_KEY = "generic_24_key" GENERIC_40_KEY = "generic_40_key" diff --git a/homeassistant/components/led_infrared/entity.py b/homeassistant/components/led_infrared/entity.py index 8738398a573bbe..5efb4fc335fc91 100644 --- a/homeassistant/components/led_infrared/entity.py +++ b/homeassistant/components/led_infrared/entity.py @@ -1,6 +1,8 @@ """Base entity for LED Infrared integration.""" from infrared_protocols.codes.generic.led import ( + BaseGenericLEDCode, + Generic10KeyCode, Generic13KeyCode, Generic24KeyCode, Generic40KeyCode, @@ -13,12 +15,10 @@ from .const import DOMAIN, LEDIrDeviceType -CODES: dict[ - LEDIrDeviceType, - type[Generic24KeyCode | Generic13KeyCode | Generic40KeyCode | Generic44KeyCode], -] = { - LEDIrDeviceType.GENERIC_24_KEY: Generic24KeyCode, +CODES: dict[LEDIrDeviceType, type[BaseGenericLEDCode]] = { + LEDIrDeviceType.GENERIC_10_KEY: Generic10KeyCode, LEDIrDeviceType.GENERIC_13_KEY: Generic13KeyCode, + LEDIrDeviceType.GENERIC_24_KEY: Generic24KeyCode, LEDIrDeviceType.GENERIC_40_KEY: Generic40KeyCode, LEDIrDeviceType.GENERIC_44_KEY: Generic44KeyCode, } diff --git a/homeassistant/components/led_infrared/icons.json b/homeassistant/components/led_infrared/icons.json index e833bfc4d0a1f9..4d7f8f8c00446c 100644 --- a/homeassistant/components/led_infrared/icons.json +++ b/homeassistant/components/led_infrared/icons.json @@ -34,6 +34,18 @@ "timer": { "default": "mdi:timer" }, + "timer_2h": { + "default": "mdi:timer" + }, + "timer_4h": { + "default": "mdi:timer" + }, + "timer_6h": { + "default": "mdi:timer" + }, + "timer_8h": { + "default": "mdi:timer" + }, "white_brightness_100": { "default": "mdi:lightbulb-on" }, @@ -72,6 +84,7 @@ "aqua": "mdi:palette", "auto": "mdi:auto-mode", "blue": "mdi:palette", + "candle": "mdi:candle", "cyan": "mdi:palette", "dark_cyan": "mdi:palette", "deep_blue": "mdi:palette", @@ -91,6 +104,7 @@ "jump3": "mdi:dots-circle", "jump7": "mdi:dots-circle", "lavender_blush": "mdi:palette", + "light": "mdi:lightbulb-on", "light_cyan": "mdi:palette", "light_green": "mdi:palette", "magenta": "mdi:palette", diff --git a/homeassistant/components/led_infrared/light.py b/homeassistant/components/led_infrared/light.py index 4aef9999309581..71849e644d5d30 100644 --- a/homeassistant/components/led_infrared/light.py +++ b/homeassistant/components/led_infrared/light.py @@ -54,6 +54,7 @@ "diy5", "diy6", ], + LEDIrDeviceType.GENERIC_10_KEY: ["candle", "light"], } diff --git a/homeassistant/components/led_infrared/strings.json b/homeassistant/components/led_infrared/strings.json index fd47bbbcd8e203..725a3c877dc620 100644 --- a/homeassistant/components/led_infrared/strings.json +++ b/homeassistant/components/led_infrared/strings.json @@ -71,6 +71,18 @@ "timer": { "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::timer%]" }, + "timer_2h": { + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::timer_2h%]" + }, + "timer_4h": { + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::timer_4h%]" + }, + "timer_6h": { + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::timer_6h%]" + }, + "timer_8h": { + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::timer_8h%]" + }, "white_brightness_100": { "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::white_brightness_100%]" }, @@ -109,6 +121,7 @@ "blue_up": "Blue up", "brightness_down": "Brightness down", "brightness_up": "Brightness up", + "candle": "[%key:component::led_infrared::entity::light::light::state_attributes::effect::state::candle%]", "cyan": "[%key:component::led_infrared::entity::light::light::state_attributes::effect::state::cyan%]", "dark_cyan": "[%key:component::led_infrared::entity::light::light::state_attributes::effect::state::dark_cyan%]", "deep_blue": "[%key:component::led_infrared::entity::light::light::state_attributes::effect::state::deep_blue%]", @@ -130,6 +143,7 @@ "jump3": "[%key:component::led_infrared::entity::light::light::state_attributes::effect::state::jump3%]", "jump7": "[%key:component::led_infrared::entity::light::light::state_attributes::effect::state::jump7%]", "lavender_blush": "[%key:component::led_infrared::entity::light::light::state_attributes::effect::state::lavender_blush%]", + "light": "[%key:component::led_infrared::entity::light::light::state_attributes::effect::state::light%]", "light_cyan": "[%key:component::led_infrared::entity::light::light::state_attributes::effect::state::light_cyan%]", "light_green": "[%key:component::led_infrared::entity::light::light::state_attributes::effect::state::light_green%]", "magenta": "[%key:component::led_infrared::entity::light::light::state_attributes::effect::state::magenta%]", @@ -158,6 +172,10 @@ "smooth": "[%key:component::led_infrared::entity::light::light::state_attributes::effect::state::smooth%]", "strobe": "[%key:component::led_infrared::entity::light::light::state_attributes::effect::state::strobe%]", "timer": "Timer", + "timer_2h": "Timer 2h", + "timer_4h": "Timer 4h", + "timer_6h": "Timer 6h", + "timer_8h": "Timer 8h", "tomato": "[%key:component::led_infrared::entity::light::light::state_attributes::effect::state::tomato%]", "turquoise": "[%key:component::led_infrared::entity::light::light::state_attributes::effect::state::turquoise%]", "white": "[%key:component::led_infrared::entity::light::light::state_attributes::effect::state::white%]", @@ -184,6 +202,7 @@ "aqua": "Color: Aqua", "auto": "[%key:common::state::auto%]", "blue": "Color: Blue", + "candle": "Candle", "cyan": "Color: Cyan", "dark_cyan": "Color: Dark cyan", "deep_blue": "Color: Deep Blue", @@ -203,6 +222,7 @@ "jump3": "Jump 3", "jump7": "Jump 7", "lavender_blush": "Color: Lavender Blush", + "light": "Steady light", "light_cyan": "Color: Light Cyan", "light_green": "Color: Light green", "magenta": "Color: Magenta", @@ -238,6 +258,7 @@ "selector": { "device_type": { "options": { + "generic_10_key": "10-key remote control", "generic_13_key": "13-key remote control", "generic_24_key": "24-key remote control", "generic_40_key": "40-key remote control", diff --git a/homeassistant/components/lg_thinq/mqtt.py b/homeassistant/components/lg_thinq/mqtt.py index 539437dd14c1ee..25ed3c2c619099 100644 --- a/homeassistant/components/lg_thinq/mqtt.py +++ b/homeassistant/components/lg_thinq/mqtt.py @@ -6,6 +6,7 @@ import logging from typing import Any +from aiohttp import ClientError from thinqconnect import ( DeviceType, ThinQApi, @@ -58,7 +59,8 @@ async def async_disconnect(self, event: Event | None = None) -> None: if self.client is not None: try: await self.client.async_disconnect() - except ThinQAPIException, TypeError, ValueError: + except ThinQAPIException, TypeError, ValueError, ClientError, TimeoutError: + # Saying goodbye is a courtesy, never a reason to fail the unload _LOGGER.exception("Failed to disconnect") def _get_failed_device_count( diff --git a/homeassistant/components/lyngdorf/diagnostics.py b/homeassistant/components/lyngdorf/diagnostics.py index 701bdfa9c6ef65..0acb33de4875df 100644 --- a/homeassistant/components/lyngdorf/diagnostics.py +++ b/homeassistant/components/lyngdorf/diagnostics.py @@ -3,6 +3,8 @@ from dataclasses import asdict from typing import Any +from lyngdorf import Trim + from homeassistant.components.diagnostics import async_redact_data from homeassistant.components.ssdp import async_get_discovery_info_by_st from homeassistant.const import CONF_HOST @@ -12,10 +14,16 @@ from .const import CONF_SERIAL_NUMBER, SSDP_ST from .models import LyngdorfConfigEntry -_TRIM_NAMES = tuple( - f"trim_{trim}" for trim in ("bass", "treble", "centre", "height", "lfe", "surround") -) -_RANGE_NAMES = ("lipsync_range", *(f"{name}_range" for name in _TRIM_NAMES)) +# Reported under the integration's own names, which do not all match the +# library's spelling of the band. +_TRIMS = { + "trim_bass": Trim.BASS, + "trim_treble": Trim.TREBLE, + "trim_centre": Trim.CENTER, + "trim_height": Trim.HEIGHT, + "trim_lfe": Trim.LFE, + "trim_surround": Trim.SURROUND, +} # The serial doubles as the device MAC and as the config entry unique_id, so it # needs redacting wherever it surfaces, including inside the UPnP description. @@ -56,43 +64,58 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" receiver = config_entry.runtime_data.receiver + volume = receiver.volume + lipsync = receiver.lipsync + zone_b = receiver.zone_b state: dict[str, Any] = { "connected": receiver.connected, "model": receiver.model.name, "power_on": receiver.power_on, - "volume": receiver.volume, + "volume": volume.value if volume is not None else None, "max_volume": receiver.max_volume, - "mute_enabled": receiver.mute_enabled, + "mute_enabled": receiver.muted, "source": receiver.source, - "available_sources": receiver.available_sources, + "available_sources": receiver.sources, "sound_mode": receiver.sound_mode, - "available_sound_modes": receiver.available_sound_modes, + "available_sound_modes": receiver.sound_modes, "audio_input": receiver.audio_input, - "available_audio_inputs": receiver.available_audio_inputs, + "available_audio_inputs": receiver.audio_inputs, "video_input": receiver.video_input, - "available_video_inputs": receiver.available_video_inputs, + "available_video_inputs": receiver.video_inputs, "audio_information": receiver.audio_information, "video_information": receiver.video_information, "streaming_source": receiver.streaming_source, - "available_stream_types": receiver.available_stream_types, + "available_stream_types": receiver.stream_types, "room_perfect_position": receiver.room_perfect_position, - "available_room_perfect_positions": receiver.available_room_perfect_positions, + "available_room_perfect_positions": receiver.room_perfect_positions, "voicing": receiver.voicing, - "available_voicings": receiver.available_voicings, - "lipsync": receiver.lipsync, - "zone_b_power_on": receiver.zone_b_power_on, - "zone_b_volume": receiver.zone_b_volume, - "zone_b_mute_enabled": receiver.zone_b_mute_enabled, - "zone_b_source": receiver.zone_b_source, - "zone_b_audio_input": receiver.zone_b_audio_input, - "zone_b_streaming_source": receiver.zone_b_streaming_source, + "available_voicings": receiver.voicings, + "lipsync": lipsync.value if lipsync is not None else None, + "zone_b_power_on": zone_b.power_on if zone_b is not None else None, + "zone_b_volume": zone_b.volume.value if zone_b is not None else None, + "zone_b_mute_enabled": zone_b.muted if zone_b is not None else None, + "zone_b_source": zone_b.source if zone_b is not None else None, + "zone_b_audio_input": zone_b.audio_input if zone_b is not None else None, + "zone_b_streaming_source": zone_b.streaming_source + if zone_b is not None + else None, + } + trims = {name: receiver.trims.get(trim) for name, trim in _TRIMS.items()} + state |= { + name: control.value if control is not None else None + for name, control in trims.items() } - state |= {name: getattr(receiver, name) for name in _TRIM_NAMES} - ranges = { - name: asdict(value) if (value := getattr(receiver, name)) is not None else None - for name in _RANGE_NAMES + # Not lipsync.range: the control reads None until the device reports a + # value, while the range is known from the model as soon as it connects. + lipsync_range = receiver.lipsync_range + ranges: dict[str, Any] = { + "lipsync_range": asdict(lipsync_range) if lipsync_range is not None else None + } + ranges |= { + f"{name}_range": asdict(control.range) if control is not None else None + for name, control in trims.items() } return async_redact_data( diff --git a/homeassistant/components/lyngdorf/media_player.py b/homeassistant/components/lyngdorf/media_player.py index e0a69c4d498997..450c9150aab241 100644 --- a/homeassistant/components/lyngdorf/media_player.py +++ b/homeassistant/components/lyngdorf/media_player.py @@ -3,10 +3,16 @@ from datetime import datetime from typing import TYPE_CHECKING, override -from lyngdorf.device import Receiver -from lyngdorf.models.base import NumericRange -from lyngdorf.states import Control, PlaybackState, Repeat -from lyngdorf.streaming import NowPlaying +from lyngdorf import ( + Control, + LyngdorfReceiver, + NowPlaying, + NumericRange, + PlaybackState, + Player, + Repeat, + ZoneB, +) from homeassistant.components.media_player import ( MediaPlayerDeviceClass, @@ -113,7 +119,7 @@ class LyngdorfDevice(LyngdorfEntity, MediaPlayerEntity): def __init__( self, - receiver: Receiver, + receiver: LyngdorfReceiver, config_entry: LyngdorfConfigEntry, device_info: DeviceInfo, translation_key: str | None, @@ -134,7 +140,7 @@ class LyngdorfZoneBDevice(LyngdorfDevice): def __init__( self, - receiver: Receiver, + receiver: LyngdorfReceiver, config_entry: LyngdorfConfigEntry, device_info: DeviceInfo, ) -> None: @@ -147,11 +153,19 @@ def __init__( "zone_b", ) + @property + def _zone_b(self) -> ZoneB: + """Return the Zone B controls; this entity exists only when it has them.""" + zone_b = self._receiver.zone_b + if TYPE_CHECKING: + assert zone_b is not None + return zone_b + @override @property def state(self) -> MediaPlayerState | None: """Return the state of the device.""" - if self._receiver.zone_b_power_on: + if self._zone_b.power_on: return MediaPlayerState.ON return MediaPlayerState.OFF @@ -159,73 +173,64 @@ def state(self) -> MediaPlayerState | None: @property def is_volume_muted(self) -> bool | None: """Return boolean if volume is currently muted.""" - return self._receiver.zone_b_mute_enabled - - @property - def _volume_range(self) -> NumericRange: - """Return the model's documented Zone B volume range.""" - volume_range = self._receiver.zone_b_volume_range - # This entity is only created for models that have a Zone B. - if TYPE_CHECKING: - assert volume_range is not None - return volume_range + return self._zone_b.muted @override @property def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" - if (volume := self._receiver.zone_b_volume) is None: + volume = self._zone_b.volume + if volume.value is None: return None - return _to_ha_volume(volume, self._volume_range) + return _to_ha_volume(volume.value, volume.range) @override async def async_turn_on(self) -> None: """Turn on media player.""" - self._receiver.zone_b_power_on = True + await self._zone_b.set_power(True) @override async def async_turn_off(self) -> None: """Turn off media player.""" - self._receiver.zone_b_power_on = False + await self._zone_b.set_power(False) @override async def async_volume_up(self) -> None: """Volume up the media player.""" - self._receiver.zone_b_volume_up() + await self._zone_b.volume.up() @override async def async_volume_down(self) -> None: """Volume down the media player.""" - self._receiver.zone_b_volume_down() + await self._zone_b.volume.down() @override async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - self._receiver.set_zone_b_volume( - _to_lyngdorf_volume(volume, self._volume_range) - ) + control = self._zone_b.volume + await control.set(_to_lyngdorf_volume(volume, control.range)) @override async def async_mute_volume(self, mute: bool) -> None: """Send mute command.""" - self._receiver.zone_b_mute_enabled = mute + await self._zone_b.set_muted(mute) @override @property def source(self) -> str | None: """Return the current input source.""" - return self._receiver.zone_b_source + return self._zone_b.source @override @property def source_list(self) -> list[str] | None: """Return the list of available sources.""" - return self._receiver.zone_b_available_sources + return self._zone_b.sources @override async def async_select_source(self, source: str) -> None: """Select input source.""" - self._receiver.zone_b_source = source + await self._zone_b.set_source(source) class LyngdorfMainDevice(LyngdorfDevice): @@ -233,7 +238,7 @@ class LyngdorfMainDevice(LyngdorfDevice): def __init__( self, - receiver: Receiver, + receiver: LyngdorfReceiver, config_entry: LyngdorfConfigEntry, device_info: DeviceInfo, ) -> None: @@ -253,10 +258,8 @@ async def async_added_to_hass(self) -> None: # drift, rather than once a second, which is all Home Assistant # needs: it stores a position and a timestamp and extrapolates. await super().async_added_to_hass() - if self._has_streamer: - self.async_on_remove( - self._receiver.register_position_jump_callback(self._handle_position) - ) + if (player := self._receiver.player) is not None: + self.async_on_remove(player.on_position_jump(self._handle_position)) @callback def _handle_position(self, _position_ms: int | None) -> None: @@ -264,31 +267,35 @@ def _handle_position(self, _position_ms: int | None) -> None: self.async_write_ha_state() @property - def _has_streamer(self) -> bool: - """Return whether this model has a streaming module at all.""" - return self._receiver.model.has_streaming_feature() + def _player(self) -> Player: + """Return the streamer; transport is only offered when it exists.""" + player = self._receiver.player + if TYPE_CHECKING: + assert player is not None + return player @property def _now_playing(self) -> NowPlaying | None: """Return the current track, or None if this model has no streamer.""" - if not self._has_streamer: + if (player := self._receiver.player) is None: return None - return self._receiver.now_playing + return player.now_playing @override @property def supported_features(self) -> MediaPlayerEntityFeature: """Return the features the device currently offers.""" features = FEATURES_MAIN - if (now_playing := self._now_playing) is None: + player = self._receiver.player + if player is None or (now_playing := player.now_playing) is None: return features for control, feature in CONTROL_FEATURES: if control in now_playing.controls: features |= feature - if self._receiver.can_shuffle: + if player.can_shuffle: features |= MediaPlayerEntityFeature.SHUFFLE_SET - if self._receiver.available_repeat_modes: + if player.repeat_modes: features |= MediaPlayerEntityFeature.REPEAT_SET return features @@ -314,25 +321,33 @@ def media_content_type(self) -> MediaType | None: @property def media_title(self) -> str | None: """Return the title of the current track.""" - return now_playing.title if (now_playing := self._now_playing) else None + if (now_playing := self._now_playing) is None: + return None + return now_playing.title @override @property def media_artist(self) -> str | None: """Return the artist of the current track.""" - return now_playing.artist if (now_playing := self._now_playing) else None + if (now_playing := self._now_playing) is None: + return None + return now_playing.artist @override @property def media_album_name(self) -> str | None: """Return the album of the current track.""" - return now_playing.album if (now_playing := self._now_playing) else None + if (now_playing := self._now_playing) is None: + return None + return now_playing.album @override @property def media_image_url(self) -> str | None: """Return the album art of the current track.""" - return now_playing.art_url if (now_playing := self._now_playing) else None + if (now_playing := self._now_playing) is None: + return None + return now_playing.art_url @override @property @@ -348,10 +363,8 @@ def media_duration(self) -> int | None: @property def media_position(self) -> int | None: """Return the position of the current track, in seconds.""" - if ( - not self._has_streamer - or (position_ms := self._receiver.position_ms) is None - ): + player = self._receiver.player + if player is None or (position_ms := player.position_ms) is None: return None return round(position_ms / 1000) @@ -359,21 +372,27 @@ def media_position(self) -> int | None: @property def media_position_updated_at(self) -> datetime | None: """Return when the position was last valid.""" - if not self._has_streamer or not self._receiver.has_position: + # The timestamp advances on every poll, including ones that report no + # position, so it is only meaningful alongside a position. + player = self._receiver.player + if player is None or player.position_ms is None: return None - return self._receiver.position_updated_at + return player.position_updated_at @override @property def shuffle(self) -> bool | None: """Return whether shuffle is enabled.""" - return self._receiver.shuffle if self._has_streamer else None + if (player := self._receiver.player) is None: + return None + return player.shuffle @override @property def repeat(self) -> RepeatMode | None: """Return the current repeat mode.""" - if not self._has_streamer or (repeat := self._receiver.repeat) is None: + player = self._receiver.player + if player is None or (repeat := player.repeat) is None: return None return REPEAT_MODES.get(repeat) @@ -383,67 +402,59 @@ async def async_media_pause(self) -> None: # On a controller-driven source such as AirPlay the device ends the # session rather than pausing, and only the controlling app can # start it again. - await self._receiver.async_pause() + await self._player.pause() @override async def async_media_next_track(self) -> None: """Skip to the next track.""" - await self._receiver.async_next() + await self._player.next_track() @override async def async_media_previous_track(self) -> None: """Skip to the previous track.""" - await self._receiver.async_previous() + await self._player.previous_track() @override async def async_media_seek(self, position: float) -> None: """Seek to a position, given in seconds.""" - await self._receiver.async_seek(round(position * 1000)) + await self._player.seek(round(position * 1000)) @override async def async_set_shuffle(self, shuffle: bool) -> None: """Enable or disable shuffle, leaving the repeat mode alone.""" - await self._receiver.async_set_shuffle(shuffle) + await self._player.set_shuffle(shuffle) @override async def async_set_repeat(self, repeat: RepeatMode) -> None: """Set the repeat mode, leaving shuffle alone.""" - await self._receiver.async_set_repeat(LYNGDORF_REPEATS[repeat]) + await self._player.set_repeat(LYNGDORF_REPEATS[repeat]) @override @property def source_list(self) -> list[str] | None: """Return a list of available input sources.""" - return self._receiver.available_sources + return self._receiver.sources @override @property def sound_mode_list(self) -> list[str] | None: """Return a list of available sound modes.""" - return self._receiver.available_sound_modes + return self._receiver.sound_modes @override @property def is_volume_muted(self) -> bool | None: """Return boolean if volume is currently muted.""" - return self._receiver.mute_enabled - - @property - def _volume_range(self) -> NumericRange: - """Return the model's documented main-zone volume range.""" - volume_range = self._receiver.volume_range - # Every supported model documents a main-zone volume range. - if TYPE_CHECKING: - assert volume_range is not None - return volume_range + return self._receiver.muted @override @property def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" - if (volume := self._receiver.volume) is None: + volume = self._receiver.volume + if volume is None or volume.value is None: return None - return _to_ha_volume(volume, self._volume_range) + return _to_ha_volume(volume.value, volume.range) @override @property @@ -460,39 +471,42 @@ def sound_mode(self) -> str | None: @override async def async_turn_on(self) -> None: """Turn on media player.""" - self._receiver.power_on = True + await self._receiver.set_power(True) @override async def async_turn_off(self) -> None: """Turn off media player.""" - self._receiver.power_on = False + await self._receiver.set_power(False) @override async def async_volume_up(self) -> None: """Volume up the media player.""" - self._receiver.volume_up() + if (volume := self._receiver.volume) is not None: + await volume.up() @override async def async_volume_down(self) -> None: """Volume down the media player.""" - self._receiver.volume_down() + if (volume := self._receiver.volume) is not None: + await volume.down() @override async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - self._receiver.set_volume(_to_lyngdorf_volume(volume, self._volume_range)) + if (control := self._receiver.volume) is not None: + await control.set(_to_lyngdorf_volume(volume, control.range)) @override async def async_mute_volume(self, mute: bool) -> None: """Send mute command.""" - self._receiver.mute_enabled = mute + await self._receiver.set_muted(mute) @override async def async_select_sound_mode(self, sound_mode: str) -> None: """Select sound mode.""" - self._receiver.sound_mode = sound_mode + await self._receiver.set_sound_mode(sound_mode) @override async def async_select_source(self, source: str) -> None: """Select input source.""" - self._receiver.source = source + await self._receiver.set_source(source) diff --git a/homeassistant/components/lyngdorf/remote.py b/homeassistant/components/lyngdorf/remote.py index 701d48562417e3..638b393a5673a6 100644 --- a/homeassistant/components/lyngdorf/remote.py +++ b/homeassistant/components/lyngdorf/remote.py @@ -3,8 +3,7 @@ from collections.abc import Iterable from typing import TYPE_CHECKING, Any, override -from lyngdorf.device import Receiver -from lyngdorf.exceptions import LyngdorfUnsupportedError +from lyngdorf import LyngdorfReceiver, LyngdorfUnsupportedError, Remote from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity from homeassistant.core import HomeAssistant @@ -29,7 +28,7 @@ async def async_setup_entry( receiver = runtime_data.receiver # The TDAI family has no remote keys at all, so it gets no remote entity. - if not receiver.has_remote_keys: + if receiver.remote is None: return async_add_entities( @@ -44,7 +43,7 @@ class LyngdorfRemote(LyngdorfEntity, RemoteEntity): def __init__( self, - receiver: Receiver, + receiver: LyngdorfReceiver, config_entry: LyngdorfConfigEntry, device_info: DeviceInfo, ) -> None: @@ -54,6 +53,14 @@ def __init__( assert config_entry.unique_id self._attr_unique_id = config_entry.unique_id + @property + def _remote(self) -> Remote: + """Return the remote; this entity exists only when the model has one.""" + remote = self._receiver.remote + if TYPE_CHECKING: + assert remote is not None + return remote + @override @property def is_on(self) -> bool | None: @@ -63,24 +70,22 @@ def is_on(self) -> bool | None: @override async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - self._receiver.power_on = True + await self._receiver.set_power(True) @override async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - self._receiver.power_on = False + await self._receiver.set_power(False) @override async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: """Send a sequence of remote keys to the device.""" # delay_secs is dropped: the library already paces its own writes. try: - self._receiver.send_remote_commands( - command, num_repeats=kwargs[ATTR_NUM_REPEATS] - ) + await self._remote.send(command, num_repeats=kwargs[ATTR_NUM_REPEATS]) except LyngdorfUnsupportedError as err: # The member value is what a caller sends: DIGIT_0 is "0", not "digit_0". - keys = sorted(key.value for key in self._receiver.available_remote_keys) + keys = sorted(key.value for key in self._remote.keys) raise ServiceValidationError( translation_domain=DOMAIN, translation_key="unsupported_remote_key", diff --git a/homeassistant/components/lyngdorf/select.py b/homeassistant/components/lyngdorf/select.py index 04f305afafecd6..b2ff28a82a7305 100644 --- a/homeassistant/components/lyngdorf/select.py +++ b/homeassistant/components/lyngdorf/select.py @@ -1,10 +1,10 @@ """Select platform for Lyngdorf integration.""" -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import TYPE_CHECKING, override -from lyngdorf.device import Receiver +from lyngdorf import LyngdorfReceiver from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant @@ -21,9 +21,10 @@ class LyngdorfSelectEntityDescription(SelectEntityDescription): """Describe a Lyngdorf select entity.""" - current_option_fn: Callable[[Receiver], str | None] - options_fn: Callable[[Receiver], list[str]] - select_option_fn: Callable[[Receiver, str], None] + current_option_fn: Callable[[LyngdorfReceiver], str | None] + options_fn: Callable[[LyngdorfReceiver], list[str]] + # None on the pinned library, a coroutine on 2.x: await whichever it is. + select_option_fn: Callable[[LyngdorfReceiver, str], Awaitable[None] | None] SELECT_ENTITIES: tuple[LyngdorfSelectEntityDescription, ...] = ( @@ -31,14 +32,14 @@ class LyngdorfSelectEntityDescription(SelectEntityDescription): key="room_perfect_position", translation_key="room_perfect_position", current_option_fn=lambda r: r.room_perfect_position, - options_fn=lambda r: r.available_room_perfect_positions, + options_fn=lambda r: r.room_perfect_positions, select_option_fn=lambda r, o: r.set_room_perfect_position(o), ), LyngdorfSelectEntityDescription( key="voicing", translation_key="voicing", current_option_fn=lambda r: r.voicing, - options_fn=lambda r: r.available_voicings, + options_fn=lambda r: r.voicings, select_option_fn=lambda r, o: r.set_voicing(o), ), ) @@ -67,7 +68,7 @@ class LyngdorfSelect(LyngdorfEntity, SelectEntity): def __init__( self, - receiver: Receiver, + receiver: LyngdorfReceiver, config_entry: LyngdorfConfigEntry, device_info: DeviceInfo, description: LyngdorfSelectEntityDescription, @@ -94,4 +95,6 @@ def options(self) -> list[str]: @override async def async_select_option(self, option: str) -> None: """Set the selected option.""" - self.entity_description.select_option_fn(self._receiver, option) + result = self.entity_description.select_option_fn(self._receiver, option) + if result is not None: + await result diff --git a/homeassistant/components/lyngdorf/sensor.py b/homeassistant/components/lyngdorf/sensor.py index 45a45c6042fa90..6a7b4b27d0ad68 100644 --- a/homeassistant/components/lyngdorf/sensor.py +++ b/homeassistant/components/lyngdorf/sensor.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, override -from lyngdorf.device import Receiver +from lyngdorf import LyngdorfReceiver from homeassistant.components.sensor import ( SensorDeviceClass, @@ -26,8 +26,8 @@ class LyngdorfSensorEntityDescription(SensorEntityDescription): """Describe a Lyngdorf sensor entity.""" - value_fn: Callable[[Receiver], str | None] - options_fn: Callable[[Receiver], list[str]] | None = None + value_fn: Callable[[LyngdorfReceiver], str | None] + options_fn: Callable[[LyngdorfReceiver], list[str]] | None = None def _known(value: str | None, options: list[str]) -> str | None: @@ -52,24 +52,24 @@ def _known(value: str | None, options: list[str]) -> str | None: key="audio_input", translation_key="audio_input", device_class=SensorDeviceClass.ENUM, - value_fn=lambda r: _known(r.audio_input, r.available_audio_inputs), - options_fn=lambda r: r.available_audio_inputs, + value_fn=lambda r: _known(r.audio_input, r.audio_inputs), + options_fn=lambda r: r.audio_inputs, entity_category=EntityCategory.DIAGNOSTIC, ), LyngdorfSensorEntityDescription( key="video_input", translation_key="video_input", device_class=SensorDeviceClass.ENUM, - value_fn=lambda r: _known(r.video_input, r.available_video_inputs), - options_fn=lambda r: r.available_video_inputs, + value_fn=lambda r: _known(r.video_input, r.video_inputs), + options_fn=lambda r: r.video_inputs, entity_category=EntityCategory.DIAGNOSTIC, ), LyngdorfSensorEntityDescription( key="streaming_source", translation_key="streaming_source", device_class=SensorDeviceClass.ENUM, - value_fn=lambda r: _known(r.streaming_source, r.available_stream_types), - options_fn=lambda r: r.available_stream_types, + value_fn=lambda r: _known(r.streaming_source, r.stream_types), + options_fn=lambda r: r.stream_types, entity_category=EntityCategory.DIAGNOSTIC, ), ) @@ -79,16 +79,20 @@ def _known(value: str | None, options: list[str]) -> str | None: key="zone_b_audio_input", translation_key="zone_b_audio_input", device_class=SensorDeviceClass.ENUM, - value_fn=lambda r: _known(r.zone_b_audio_input, r.available_audio_inputs), - options_fn=lambda r: r.available_audio_inputs, + value_fn=lambda r: ( + _known(zb.audio_input, r.audio_inputs) if (zb := r.zone_b) else None + ), + options_fn=lambda r: r.audio_inputs, entity_category=EntityCategory.DIAGNOSTIC, ), LyngdorfSensorEntityDescription( key="zone_b_streaming_source", translation_key="zone_b_streaming_source", device_class=SensorDeviceClass.ENUM, - value_fn=lambda r: _known(r.zone_b_streaming_source, r.available_stream_types), - options_fn=lambda r: r.available_stream_types, + value_fn=lambda r: ( + _known(zb.streaming_source, r.stream_types) if (zb := r.zone_b) else None + ), + options_fn=lambda r: r.stream_types, entity_category=EntityCategory.DIAGNOSTIC, ), ) @@ -131,7 +135,7 @@ class LyngdorfSensor(LyngdorfEntity, SensorEntity): def __init__( self, - receiver: Receiver, + receiver: LyngdorfReceiver, config_entry: LyngdorfConfigEntry, device_info: DeviceInfo, description: LyngdorfSensorEntityDescription, diff --git a/homeassistant/components/mcp_server/__init__.py b/homeassistant/components/mcp_server/__init__.py index c4b8ad952ba574..6fbec9a11ff54a 100644 --- a/homeassistant/components/mcp_server/__init__.py +++ b/homeassistant/components/mcp_server/__init__.py @@ -5,7 +5,7 @@ from homeassistant.helpers.typing import ConfigType from . import http -from .const import DOMAIN +from .const import CONF_REQUIRE_ADMIN, DOMAIN from .session import SessionManager from .types import MCPServerConfigEntry @@ -23,6 +23,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, entry: MCPServerConfigEntry) -> bool: + """Migrate a config entry.""" + if entry.version == 1 and entry.minor_version == 1: + # 1.1 -> 1.2: Endpoints served before this option existed stay open. + # A disabled config entry migrates only once enabled, so keep the + # choice the options flow may have saved in the meantime. + hass.config_entries.async_update_entry( + entry, + data={CONF_REQUIRE_ADMIN: False, **entry.data}, + minor_version=2, + ) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: MCPServerConfigEntry) -> bool: """Set up Model Context Protocol Server from a config entry.""" diff --git a/homeassistant/components/mcp_server/config_flow.py b/homeassistant/components/mcp_server/config_flow.py index 0609a7aaebd8fd..38f0e4685d786c 100644 --- a/homeassistant/components/mcp_server/config_flow.py +++ b/homeassistant/components/mcp_server/config_flow.py @@ -15,12 +15,13 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import llm from homeassistant.helpers.selector import ( + BooleanSelector, SelectOptionDict, SelectSelector, SelectSelectorConfig, ) -from .const import DOMAIN +from .const import CONF_REQUIRE_ADMIN, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -68,10 +69,22 @@ def _llm_api_schema(llm_apis: dict[str, str], default: list[str]) -> vol.Schema: ) +def _options_schema( + llm_apis: dict[str, str], default: list[str], require_admin: bool +) -> vol.Schema: + """Return the schema for the options flow.""" + return _llm_api_schema(llm_apis, default).extend( + { + vol.Required(CONF_REQUIRE_ADMIN, default=require_admin): BooleanSelector(), + } + ) + + class ModelContextServerProtocolConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Model Context Protocol Server.""" VERSION = 1 + MINOR_VERSION = 2 @staticmethod @callback @@ -95,7 +108,7 @@ async def async_step_user( else: return self.async_create_entry( title=_llm_api_title(llm_apis, user_input[CONF_LLM_HASS_API]), - data=user_input, + data={**user_input, CONF_REQUIRE_ADMIN: True}, ) return self.async_show_form( @@ -137,7 +150,12 @@ async def async_step_init( return self.async_show_form( step_id="init", - data_schema=_llm_api_schema(llm_apis, current), + data_schema=_options_schema( + llm_apis, + current, + # A disabled config entry has not migrated yet + self.config_entry.data.get(CONF_REQUIRE_ADMIN, False), + ), description_placeholders={"more_info_url": MORE_INFO_URL}, errors=errors, ) diff --git a/homeassistant/components/mcp_server/const.py b/homeassistant/components/mcp_server/const.py index 23aa84de42860f..b2ba2ef5ba234c 100644 --- a/homeassistant/components/mcp_server/const.py +++ b/homeassistant/components/mcp_server/const.py @@ -1,6 +1,7 @@ """Constants for the Model Context Protocol Server integration.""" DOMAIN = "mcp_server" +CONF_REQUIRE_ADMIN = "require_admin" TITLE = "Model Context Protocol Server" # The Stateless API is no longer registered explicitly, but this # name may still exist in the users config entry. diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py index 6f2857f0d9caa2..c36cbc0e9f7e4d 100644 --- a/homeassistant/components/mcp_server/http.py +++ b/homeassistant/components/mcp_server/http.py @@ -8,8 +8,8 @@ - /api/mcp: The Streamable HTTP endpoint currently implements the stateless protocol for simplicity. This receives client requests and sends them to the MCP server, then waits for a response to send back to - the client. This serves the configured LLM APIs and does not require - admin access. + the client. This serves the configured LLM APIs and requires admin access + when the config entry is configured to require it. - /api/mcp/: The same Streamable HTTP endpoint, but exposing a specific LLM API selected by its ID. These endpoints require admin access, except for the Assist API. @@ -50,7 +50,7 @@ from homeassistant.exceptions import Unauthorized from homeassistant.helpers import llm -from .const import DOMAIN +from .const import CONF_REQUIRE_ADMIN, DOMAIN from .server import create_server from .session import Session from .types import MCPServerConfigEntry @@ -93,6 +93,12 @@ def async_get_config_entry(hass: HomeAssistant) -> MCPServerConfigEntry: return config_entries[0] +def _validate_admin(request: web.Request, entry: MCPServerConfigEntry) -> None: + """Verify the user may use the endpoints serving the configured LLM APIs.""" + if entry.data[CONF_REQUIRE_ADMIN] and not request["hass_user"].is_admin: + raise Unauthorized + + @dataclass class Streams: """Pairs of streams for MCP server communication.""" @@ -173,6 +179,7 @@ async def get(self, request: web.Request) -> web.StreamResponse: """ hass = request.app[KEY_HASS] entry = async_get_config_entry(hass) + _validate_admin(request, entry) session_manager = entry.runtime_data server, options = await create_mcp_server( @@ -225,6 +232,7 @@ async def post( """ hass = request.app[KEY_HASS] config_entry = async_get_config_entry(hass) + _validate_admin(request, config_entry) session_manager = config_entry.runtime_data if (session := session_manager.get(session_id)) is None: @@ -297,7 +305,8 @@ async def run_server() -> None: class ModelContextProtocolStreamableView(HomeAssistantView): """Model Context Protocol Streamable HTTP endpoint. - This serves the configured LLM APIs and does not require admin access. + This serves the configured LLM APIs and requires admin access when the + config entry is configured to require it. """ name = f"{DOMAIN}:streamable" @@ -307,6 +316,7 @@ async def post(self, request: web.Request) -> web.StreamResponse: """Process JSON-RPC messages for the configured LLM APIs.""" hass = request.app[KEY_HASS] entry = async_get_config_entry(hass) + _validate_admin(request, entry) return await _async_handle_streamable_message( request, self.context(request), entry.data[CONF_LLM_HASS_API] ) diff --git a/homeassistant/components/mcp_server/strings.json b/homeassistant/components/mcp_server/strings.json index 5faaac97026d19..6f6f13597d1c75 100644 --- a/homeassistant/components/mcp_server/strings.json +++ b/homeassistant/components/mcp_server/strings.json @@ -25,10 +25,12 @@ "step": { "init": { "data": { - "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]" + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "require_admin": "Require an administrator account" }, "data_description": { - "llm_hass_api": "[%key:component::mcp_server::config::step::user::data_description::llm_hass_api%]" + "llm_hass_api": "[%key:component::mcp_server::config::step::user::data_description::llm_hass_api%]", + "require_admin": "Only allow administrator accounts to use the Model Context Protocol endpoint." }, "description": "[%key:component::mcp_server::config::step::user::description%]" } diff --git a/homeassistant/components/peblar/__init__.py b/homeassistant/components/peblar/__init__.py index 1c97f6dec0ca24..08c301dd5fc972 100644 --- a/homeassistant/components/peblar/__init__.py +++ b/homeassistant/components/peblar/__init__.py @@ -27,6 +27,7 @@ PeblarVersionDataUpdateCoordinator, ) from .services import async_setup_services +from .websocket import PeblarSessionListener CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -96,6 +97,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bo version_coordinator=version_coordinator, ) + listener = PeblarSessionListener(hass, entry, peblar, meter_coordinator) + entry.async_create_background_task( + hass, listener.async_run(), name=f"Peblar {entry.title} event stream" + ) + # Forward the setup to the platforms await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/peblar/const.py b/homeassistant/components/peblar/const.py index 0982e1ef235215..9673fac88ae368 100644 --- a/homeassistant/components/peblar/const.py +++ b/homeassistant/components/peblar/const.py @@ -1,5 +1,6 @@ """Constants for the Peblar integration.""" +from datetime import timedelta import logging from typing import Final @@ -10,6 +11,25 @@ CONF_EVCC_ID: Final = "evcc_id" CONF_UID: Final = "uid" +# The stream only makes the poll quicker, so there is no hurry, and no +# point hammering a charger that is switched off. +EVENT_STREAM_RETRY_MINIMUM: Final = timedelta(seconds=5) +EVENT_STREAM_RETRY_MAXIMUM: Final = timedelta(minutes=5) + +# How long a charger gets to start rebooting after it was asked to install +# a package. It downloads first, so this is generous: Peblar's own web +# interface waits the same three hours before it gives up. +UPDATE_REBOOT_START_TIMEOUT: Final = timedelta(hours=3) + +# And how long it gets to come back once it has actually gone. Peblar +# allows ten minutes for that. +UPDATE_REBOOT_RETURN_TIMEOUT: Final = timedelta(minutes=10) + +# How long the charger has to stay away before it counts as having +# rebooted. Peblar allows ten minutes for a reboot, so it is nowhere near +# a matter of seconds; anything shorter is the network dropping a poll. +UPDATE_REBOOT_MINIMUM_DOWNTIME: Final = timedelta(seconds=30) + LOGGER = logging.getLogger(__package__) PEBLAR_CHARGE_LIMITER_TO_HOME_ASSISTANT = { diff --git a/homeassistant/components/peblar/coordinator.py b/homeassistant/components/peblar/coordinator.py index 6a04b34debe414..1bbcd0addfd449 100644 --- a/homeassistant/components/peblar/coordinator.py +++ b/homeassistant/components/peblar/coordinator.py @@ -2,7 +2,7 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass -from datetime import timedelta +from datetime import datetime, timedelta from typing import Any, Concatenate, override from peblar import ( @@ -20,11 +20,19 @@ ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - -from .const import DOMAIN, LOGGER +from homeassistant.util import dt as dt_util + +from .const import ( + DOMAIN, + LOGGER, + UPDATE_REBOOT_MINIMUM_DOWNTIME, + UPDATE_REBOOT_RETURN_TIMEOUT, + UPDATE_REBOOT_START_TIMEOUT, +) @dataclass(kw_only=True) @@ -111,11 +119,17 @@ class PeblarVersionDataUpdateCoordinator( ): """Class to manage fetching Peblar version information.""" + config_entry: PeblarConfigEntry + + install_in_progress = False + """Set while the charger is busy installing a package.""" + def __init__( self, hass: HomeAssistant, entry: PeblarConfigEntry, peblar: Peblar ) -> None: """Initialize the coordinator.""" self.peblar = peblar + self._reboot_watcher: _RebootWatcher | None = None super().__init__( hass, LOGGER, @@ -123,6 +137,7 @@ def __init__( name=f"Peblar {entry.title} version", update_interval=timedelta(hours=2), ) + entry.async_on_unload(self.async_stop_reboot_watcher) @_coordinator_exception_handler @override @@ -133,6 +148,145 @@ async def _async_update_data(self) -> PeblarVersionInformation: available=await self.peblar.available_versions(), ) + @callback + def async_refresh_after_restart(self) -> None: + """Read the versions again once the charger has restarted. + + Installing a package returns long before the charger is done: it + downloads, then reboots on its own. Rather than guess at a delay, + wait for the charger to drop off and come back, which the data + poll notices. Peblar's own web interface waits for the same two + moments, and allows a different amount of time for each. + + Without this a charger that just updated keeps offering the update + it already took, until the two hourly version poll comes round. + """ + # Only ever one at a time: the update component refuses an install + # while the entity reports one in progress, which it does for as + # long as a watcher is running. + self.install_in_progress = True + self._reboot_watcher = _RebootWatcher(self) + self._reboot_watcher.async_start() + + @callback + def async_stop_reboot_watcher(self) -> None: + """Stop waiting on a reboot, if anything is still waiting on one.""" + if (watcher := self._reboot_watcher) is None: + return + + # Dropped first, so a watcher stopping itself cannot come back + # round here and stop itself again. + self._reboot_watcher = None + watcher.async_stop() + + +class _RebootWatcher: + """Waits out the reboot that follows installing a package. + + Two phases, because they are allowed very different amounts of time: + the charger downloads before it reboots, so going down at all may take + hours, while coming back afterwards should take minutes. + """ + + def __init__(self, coordinator: PeblarVersionDataUpdateCoordinator) -> None: + """Initialize the watcher.""" + self._coordinator = coordinator + self._entry = coordinator.config_entry + self._data_coordinator = self._entry.runtime_data.data_coordinator + self._went_down_at: datetime | None = None + self._start_deadline: datetime | None = None + self._unsubscribe_listener: CALLBACK_TYPE | None = None + self._unsubscribe_timer: CALLBACK_TYPE | None = None + + @callback + def async_start(self) -> None: + """Start watching for the charger to go away and come back.""" + self._unsubscribe_listener = self._data_coordinator.async_add_listener( + self._handle_data_coordinator_update + ) + self._start_deadline = dt_util.utcnow() + UPDATE_REBOOT_START_TIMEOUT + self._async_set_deadline(UPDATE_REBOOT_START_TIMEOUT) + + @callback + def _async_set_deadline(self, timeout: timedelta) -> None: + """Give up if nothing happens within the given time.""" + if self._unsubscribe_timer is not None: + self._unsubscribe_timer() + self._unsubscribe_timer = async_call_later( + self._coordinator.hass, timeout, self._handle_deadline + ) + + @callback + def _handle_deadline(self, _now: datetime) -> None: + """Stop watching a charger that never did what was asked.""" + self._unsubscribe_timer = None + self._coordinator.async_stop_reboot_watcher() + + @callback + def _async_unsubscribe(self) -> None: + """Stop following the charger.""" + if self._unsubscribe_listener is not None: + self._unsubscribe_listener() + self._unsubscribe_listener = None + if self._unsubscribe_timer is not None: + self._unsubscribe_timer() + self._unsubscribe_timer = None + + @callback + def async_stop(self) -> None: + """Stop watching, however it ended.""" + self._async_unsubscribe() + + # The install is over as far as anyone here can tell, whether the + # charger came back or ran out of time. + self._coordinator.install_in_progress = False + self._coordinator.async_update_listeners() + + @callback + def _handle_data_coordinator_update(self) -> None: + """Follow the charger through its reboot.""" + if not self._data_coordinator.last_update_success: + if self._went_down_at is None: + # It may have started rebooting, so the shorter allowance + # applies from here on. + self._went_down_at = dt_util.utcnow() + self._async_set_deadline(UPDATE_REBOOT_RETURN_TIMEOUT) + return + + # Still reachable, so the charger has not started rebooting yet. + if self._went_down_at is None: + return + + if dt_util.utcnow() - self._went_down_at < UPDATE_REBOOT_MINIMUM_DOWNTIME: + # Gone for a moment is the network, not a charger rebooting. Go + # back to waiting for the reboot to start, on what is left of the + # original allowance: blips must not keep extending it. + self._went_down_at = None + assert self._start_deadline is not None + self._async_set_deadline( + max(self._start_deadline - dt_util.utcnow(), timedelta(0)) + ) + return + + # The polls keep coming, so stop following the charger right away + # rather than leave this to fire a second time. + self._async_unsubscribe() + self._entry.async_create_task( + self._coordinator.hass, self._async_finish(), eager_start=False + ) + + async def _async_finish(self) -> None: + """Read the new versions before saying the install is done. + + Letting go first would publish "nothing installing" next to the + versions from before the update, and for as long as the read takes, + the charger would be offering the package it just took. + """ + try: + await self._coordinator.async_request_refresh() + finally: + self._coordinator.async_stop_reboot_watcher() + class PeblarDataUpdateCoordinator(DataUpdateCoordinator[PeblarData]): """Class to manage fetching Peblar active data.""" diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index 2f610ef127e968..53487258331550 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -209,6 +209,9 @@ "communication_error": { "message": "An error occurred while communicating with the Peblar EV charger: {error}" }, + "customization_update_first": { + "message": "Install the customization update before the firmware update, the way the charger's own web interface does." + }, "managed_by_backoffice": { "message": "{charger} is managed over OCPP, so its sessions are authorized by the backoffice." }, diff --git a/homeassistant/components/peblar/update.py b/homeassistant/components/peblar/update.py index 8c103d115930d7..53fc2e2c61f043 100644 --- a/homeassistant/components/peblar/update.py +++ b/homeassistant/components/peblar/update.py @@ -2,26 +2,40 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import override +from typing import Any, override + +from peblar import PackageType from homeassistant.components.update import ( UpdateDeviceClass, UpdateEntity, UpdateEntityDescription, + UpdateEntityFeature, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import DOMAIN from .coordinator import ( PeblarConfigEntry, PeblarVersionDataUpdateCoordinator, PeblarVersionInformation, ) from .entity import PeblarEntity +from .helpers import peblar_exception_handler PARALLEL_UPDATES = 1 +def _customization_update_pending(versions: PeblarVersionInformation) -> bool: + """Return whether a customization package is waiting to be installed.""" + return ( + versions.available.customization is not None + and versions.available.customization != versions.current.customization + ) + + @dataclass(frozen=True, kw_only=True) class PeblarUpdateEntityDescription(UpdateEntityDescription): """Describe an Peblar update entity.""" @@ -29,12 +43,14 @@ class PeblarUpdateEntityDescription(UpdateEntityDescription): available_fn: Callable[[PeblarVersionInformation], str | None] has_fn: Callable[[PeblarVersionInformation], bool] = lambda _: True installed_fn: Callable[[PeblarVersionInformation], str | None] + package_type: PackageType DESCRIPTIONS: tuple[PeblarUpdateEntityDescription, ...] = ( PeblarUpdateEntityDescription( key="firmware", device_class=UpdateDeviceClass.FIRMWARE, + package_type=PackageType.FIRMWARE, installed_fn=lambda x: x.current.firmware, has_fn=lambda x: x.available.firmware is not None, available_fn=lambda x: x.available.firmware, @@ -42,6 +58,7 @@ class PeblarUpdateEntityDescription(UpdateEntityDescription): PeblarUpdateEntityDescription( key="customization", translation_key="customization", + package_type=PackageType.CUSTOMIZATION, available_fn=lambda x: x.available.customization, has_fn=lambda x: x.available.customization is not None, installed_fn=lambda x: x.current.customization, @@ -74,6 +91,20 @@ class PeblarUpdateEntity( entity_description: PeblarUpdateEntityDescription + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) + + @property + @override + def in_progress(self) -> bool: + """Return whether the charger is busy installing a package. + + No percentage goes with it: the charger reports only whether an + update succeeded, never how far along it is. + """ + return self.coordinator.install_in_progress + @property @override def installed_version(self) -> str | None: @@ -85,3 +116,41 @@ def installed_version(self) -> str | None: def latest_version(self) -> str | None: """Latest version available for install.""" return self.entity_description.available_fn(self.coordinator.data) + + @peblar_exception_handler + @override + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install the package the charger has on offer.""" + if self.entity_description.package_type is PackageType.FIRMWARE: + await self._async_raise_if_customization_pending() + + await self.coordinator.peblar.update( + package_type=self.entity_description.package_type + ) + self.coordinator.async_refresh_after_restart() + + async def _async_raise_if_customization_pending(self) -> None: + """Refuse firmware while a customization package is still waiting. + + Peblar's own web interface installs the customization package first + and waits for the charger to come back before it touches the + firmware. Doing it the other way around is not a sequence the + charger is put through anywhere else. + + Versions are polled once every two hours, and the charger answers + from its own cache unless told not to. Both are asked again here: + a customization published since the last poll is exactly the case + this refusal is for, and it would otherwise walk straight past it. + """ + versions = PeblarVersionInformation( + current=await self.coordinator.peblar.current_versions(), + available=await self.coordinator.peblar.available_versions(use_cache=False), + ) + + if _customization_update_pending(versions): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="customization_update_first", + ) diff --git a/homeassistant/components/peblar/websocket.py b/homeassistant/components/peblar/websocket.py new file mode 100644 index 00000000000000..d01558b1bd5232 --- /dev/null +++ b/homeassistant/components/peblar/websocket.py @@ -0,0 +1,80 @@ +"""Live event stream for the Peblar integration.""" + +import asyncio + +from peblar import Peblar, PeblarError, PeblarSessionStatus + +from homeassistant.core import HomeAssistant, callback + +from .const import EVENT_STREAM_RETRY_MAXIMUM, EVENT_STREAM_RETRY_MINIMUM, LOGGER +from .coordinator import PeblarConfigEntry, PeblarDataUpdateCoordinator + + +class PeblarSessionListener: + """Follows the charging session over the charger's event stream. + + The charger pushes a session change as it happens, which the poll + would otherwise take up to its interval to notice. This only tells the + poll to catch up early, so a stream that never comes up, or one that + falls over, costs nothing beyond going back to the poll on its own. + """ + + def __init__( + self, + hass: HomeAssistant, + entry: PeblarConfigEntry, + peblar: Peblar, + coordinator: PeblarDataUpdateCoordinator, + ) -> None: + """Initialize the listener.""" + self._hass = hass + self._entry = entry + self._peblar = peblar + self._coordinator = coordinator + self._retry = EVENT_STREAM_RETRY_MINIMUM + + async def async_run(self) -> None: + """Keep a subscription up for as long as the entry is loaded.""" + self._retry = EVENT_STREAM_RETRY_MINIMUM + + while True: + try: + await self._async_listen() + except PeblarError as error: + LOGGER.debug( + "Peblar event stream for %s stopped: %s", self._entry.title, error + ) + + await asyncio.sleep(self._retry.total_seconds()) + self._retry = min(self._retry * 2, EVENT_STREAM_RETRY_MAXIMUM) + + async def _async_listen(self) -> None: + """Open the stream and stay on it until it closes.""" + websocket = self._peblar.websocket() + try: + await websocket.connect() + await websocket.subscribe_session_status(self._handle_session_status) + + # A charger that was unreachable at startup can leave the wait + # at its longest. A subscription that landed settles that, so a + # drop hours later is not held against whatever went before. + # Taking the socket without ever getting this far is not a + # working stream, and keeps backing off. + self._retry = EVENT_STREAM_RETRY_MINIMUM + + await websocket.listen() + finally: + await websocket.disconnect() + + @callback + def _handle_session_status(self, status: PeblarSessionStatus) -> None: + """Ask the poll to catch up, now the session has moved on. + + The charger sends the current status right after subscribing, so + the first call says nothing new. Refreshing anyway is harmless and + cheaper than working out which one that was. + """ + LOGGER.debug("Peblar session for %s is %s", self._entry.title, status.state) + self._entry.async_create_task( + self._hass, self._coordinator.async_request_refresh(), eager_start=False + ) diff --git a/homeassistant/components/prana/fan.py b/homeassistant/components/prana/fan.py index 718b7edfb09e3b..f7c494475189e3 100644 --- a/homeassistant/components/prana/fan.py +++ b/homeassistant/components/prana/fan.py @@ -3,7 +3,6 @@ from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum -import math from typing import Any, override from prana_local_api_client.models.prana_state import FanState @@ -137,11 +136,14 @@ async def async_set_percentage(self, percentage: int) -> None: await self.async_turn_off() return await self.coordinator.api_client.set_speed( - math.ceil( - percentage_to_ranged_value( - self.entity_description.speed_range(self.coordinator), - percentage, - ) + max( + 1, + round( + percentage_to_ranged_value( + self.entity_description.speed_range(self.coordinator), + percentage, + ) + ), ) * PRANA_SPEED_MULTIPLIER, self._api_target_key, diff --git a/homeassistant/components/silla_prism/config_flow.py b/homeassistant/components/silla_prism/config_flow.py index d6ac590158ad6d..3b793f97aaea29 100644 --- a/homeassistant/components/silla_prism/config_flow.py +++ b/homeassistant/components/silla_prism/config_flow.py @@ -50,14 +50,16 @@ async def async_step_user( await self.async_set_unique_id(base_topic) self._abort_if_unique_id_configured() + # Nothing the user can enter in this form brings the broker + # back, so this is a dead end rather than a form error. if not await async_wait_for_mqtt_client(self.hass): - errors["base"] = "mqtt_unavailable" - elif not await self._async_probe(base_topic): - errors["base"] = "no_device" - else: + return self.async_abort(reason="mqtt_unavailable") + + if await self._async_probe(base_topic): return self.async_create_entry( title="Silla Prism", data={CONF_BASE_TOPIC: base_topic} ) + errors["base"] = "no_device" return self.async_show_form( step_id="user", diff --git a/homeassistant/components/silla_prism/strings.json b/homeassistant/components/silla_prism/strings.json index 677608819c001c..2b7dce9a180d85 100644 --- a/homeassistant/components/silla_prism/strings.json +++ b/homeassistant/components/silla_prism/strings.json @@ -2,11 +2,11 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "invalid_discovery_info": "Invalid discovery information received." + "invalid_discovery_info": "Invalid discovery information received.", + "mqtt_unavailable": "The MQTT integration is not available. Set it up first." }, "error": { "invalid_base_topic": "This is not a valid MQTT topic. Enter the plain topic prefix from the Silla app, without wildcards.", - "mqtt_unavailable": "The MQTT integration is not available. Set it up first.", "no_device": "No Prism messages were received on this base topic. Check that the topic matches the one configured in the Silla app and that Prism is online." }, "flow_title": "{serial}", diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 9ff307942241c4..bc6ed4951310c7 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -415,6 +415,7 @@ def __init__(self, client: SmartThings, device: FullDevice) -> None: Capability.THERMOSTAT_COOLING_SETPOINT, Capability.TEMPERATURE_MEASUREMENT, Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, Capability.DEMAND_RESPONSE_LOAD_CONTROL, }, ) @@ -633,28 +634,55 @@ def target_temperature_step(self) -> float | None: step, self._setpoint_range_unit, self.temperature_unit ) + def _get_custom_setpoint(self, attribute: Attribute) -> float | None: + """Return a setpoint bound from the custom setpoint control capability.""" + if not self.supports_capability(Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL): + return None + setpoint = self.get_attribute_value( + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, attribute + ) + # Devices report -1000 when the bound is not available + if setpoint is None or setpoint == -1000: + return None + unit = self._internal_state[Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL][ + attribute + ].unit + return TemperatureConverter.convert( + setpoint, + UNIT_MAP[unit] if unit else self.temperature_unit, + self.temperature_unit, + ) + @property @override def min_temp(self) -> float: """Return the minimum temperature.""" - if (minimum := self._get_setpoint_range_value("minimum")) is None: + if (minimum := self._get_setpoint_range_value("minimum")) is not None: return TemperatureConverter.convert( - DEFAULT_MIN_TEMP, UnitOfTemperature.CELSIUS, self.temperature_unit + minimum, self._setpoint_range_unit, self.temperature_unit ) + if ( + minimum := self._get_custom_setpoint(Attribute.MINIMUM_SETPOINT) + ) is not None: + return minimum return TemperatureConverter.convert( - minimum, self._setpoint_range_unit, self.temperature_unit + DEFAULT_MIN_TEMP, UnitOfTemperature.CELSIUS, self.temperature_unit ) @property @override def max_temp(self) -> float: """Return the maximum temperature.""" - if (maximum := self._get_setpoint_range_value("maximum")) is None: + if (maximum := self._get_setpoint_range_value("maximum")) is not None: return TemperatureConverter.convert( - DEFAULT_MAX_TEMP, UnitOfTemperature.CELSIUS, self.temperature_unit + maximum, self._setpoint_range_unit, self.temperature_unit ) + if ( + maximum := self._get_custom_setpoint(Attribute.MAXIMUM_SETPOINT) + ) is not None: + return maximum return TemperatureConverter.convert( - maximum, self._setpoint_range_unit, self.temperature_unit + DEFAULT_MAX_TEMP, UnitOfTemperature.CELSIUS, self.temperature_unit ) @property diff --git a/homeassistant/components/smtp/__init__.py b/homeassistant/components/smtp/__init__.py index 48a6ff3f90f634..397bf4953ebac4 100644 --- a/homeassistant/components/smtp/__init__.py +++ b/homeassistant/components/smtp/__init__.py @@ -1,8 +1,8 @@ """The smtp integration.""" import logging -from smtplib import SMTPAuthenticationError -from socket import gaierror + +from aiosmtplib import SMTP, SMTPAuthenticationError, SMTPException from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.config_entries import ConfigEntry @@ -11,7 +11,6 @@ CONF_PASSWORD, CONF_PORT, CONF_RECIPIENT, - CONF_SENDER, CONF_TIMEOUT, CONF_USERNAME, CONF_VERIFY_SSL, @@ -25,23 +24,14 @@ entity_registry as er, ) from homeassistant.helpers.typing import ConfigType -from homeassistant.util.ssl import create_client_context - -from .const import ( - CONF_ENCRYPTION, - CONF_ENTRY, - CONF_OLD_RECIPIENT, - CONF_SENDER_NAME, - CONF_SERVER, - DEFAULT_TIMEOUT, - DOMAIN, -) -from .helpers import SmtpClient +from homeassistant.util.ssl import client_context, client_context_no_verify + +from .const import CONF_ENCRYPTION, CONF_ENTRY, CONF_OLD_RECIPIENT, CONF_SERVER, DOMAIN from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -type SmtpConfigEntry = ConfigEntry[SmtpClient] +type SmtpConfigEntry = ConfigEntry[SMTP] PLATFORMS: list[Platform] = [Platform.NOTIFY] @@ -75,30 +65,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmtpConfigEntry) -> bool {}, ) ) - client = SmtpClient( - server=entry.data[CONF_SERVER], + + client = SMTP( + hostname=entry.data[CONF_SERVER], port=entry.data[CONF_PORT], - timeout=entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), - sender=entry.data[CONF_SENDER], - encryption=entry.data[CONF_ENCRYPTION], username=entry.data.get(CONF_USERNAME), password=entry.data.get(CONF_PASSWORD), - sender_name=entry.data.get(CONF_SENDER_NAME), - verify_ssl=entry.data[CONF_VERIFY_SSL], - ssl_context=( - await hass.async_add_executor_job(create_client_context) + timeout=entry.options.get(CONF_TIMEOUT), + use_tls=entry.data[CONF_ENCRYPTION] == "tls", + start_tls=entry.data[CONF_ENCRYPTION] == "starttls", + tls_context=( + client_context() if entry.data[CONF_VERIFY_SSL] - else None + else client_context_no_verify() ), ) try: - await hass.async_add_executor_job(lambda: client.connect().quit()) + async with client: + pass except SMTPAuthenticationError as e: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="authentication_error", ) from e - except (gaierror, ConnectionRefusedError) as e: + except SMTPException as e: _LOGGER.debug("Full exception:", exc_info=True) raise ConfigEntryNotReady( translation_domain=DOMAIN, diff --git a/homeassistant/components/smtp/config_flow.py b/homeassistant/components/smtp/config_flow.py index a2306edc13226e..61a91ab02ff222 100644 --- a/homeassistant/components/smtp/config_flow.py +++ b/homeassistant/components/smtp/config_flow.py @@ -1,13 +1,10 @@ """Config flow for the SMTP integration.""" from collections.abc import Mapping -from contextlib import suppress import logging -from smtplib import SMTP, SMTP_SSL, SMTPAuthenticationError, SMTPException -import socket -from ssl import SSLCertVerificationError from typing import Any, override +from aiosmtplib import SMTP, SMTPAuthenticationError, SMTPException, SMTPTimeoutError import voluptuous as vol from homeassistant import data_entry_flow @@ -33,7 +30,7 @@ CONF_VERIFY_SSL, UnitOfTime, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( NumberSelector, @@ -46,7 +43,7 @@ TextSelectorConfig, TextSelectorType, ) -from homeassistant.util.ssl import create_client_context +from homeassistant.util.ssl import client_context, client_context_no_verify from . import SmtpConfigEntry from .const import ( @@ -169,9 +166,7 @@ async def async_step_user( ) entry_data = user_input.copy() options = entry_data.pop(SECTION_OPTIONS) - errors = await self.hass.async_add_executor_job( - validate_input, entry_data, options - ) + errors = await validate_input(self.hass, entry_data, options) if not errors: return self.async_create_entry( title=entry_data.get(CONF_SENDER_NAME, entry_data[CONF_SENDER]), @@ -223,9 +218,7 @@ async def async_step_reconfigure( CONF_USERNAME: user_input.get(CONF_USERNAME), } ) - errors = await self.hass.async_add_executor_job( - validate_input, user_input, dict(entry.options) - ) + errors = await validate_input(self.hass, user_input, dict(entry.options)) if not errors: return self.async_update_and_abort( entry, @@ -255,8 +248,8 @@ async def async_step_reauth_confirm( entry = self._get_reauth_entry() if user_input is not None: - errors = await self.hass.async_add_executor_job( - validate_input, {**entry.data, **user_input}, dict(entry.options) + errors = await validate_input( + self.hass, {**entry.data, **user_input}, dict(entry.options) ) if not errors: return self.async_update_and_abort( @@ -279,9 +272,8 @@ async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResu options = {CONF_TIMEOUT: import_info.pop(CONF_TIMEOUT, DEFAULT_TIMEOUT)} self._async_abort_entries_match(import_info) - errors = await self.hass.async_add_executor_job( - validate_input, import_info, options - ) + errors = await validate_input(self.hass, import_info, options) + if not errors: title = ( import_info.get(CONF_NAME) @@ -306,49 +298,36 @@ async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResu return self.async_abort(reason=errors["base"]) -def validate_input( - user_input: dict[str, Any], options: dict[str, Any] +async def validate_input( + hass: HomeAssistant, user_input: dict[str, Any], options: dict[str, Any] ) -> dict[str, str]: """Validate the user input allows us to connect.""" errors: dict[str, str] = {} - ssl_context = create_client_context() if user_input[CONF_VERIFY_SSL] else None - mail: SMTP_SSL | SMTP | None = None try: - if user_input[CONF_ENCRYPTION] == "tls": - mail = SMTP_SSL( - user_input[CONF_SERVER], - user_input[CONF_PORT], - timeout=options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), - context=ssl_context, - ) - else: - mail = SMTP( - user_input[CONF_SERVER], - user_input[CONF_PORT], - timeout=options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), - ) - mail.ehlo_or_helo_if_needed() - if user_input[CONF_ENCRYPTION] == "starttls": - mail.starttls(context=ssl_context) - mail.ehlo() - if user_input.get(CONF_USERNAME) and user_input.get(CONF_PASSWORD): - mail.login(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) - - except TimeoutError: + async with SMTP( + hostname=user_input[CONF_SERVER], + port=user_input[CONF_PORT], + username=user_input.get(CONF_USERNAME), + password=user_input.get(CONF_PASSWORD), + timeout=options.get(CONF_TIMEOUT), + use_tls=user_input[CONF_ENCRYPTION] == "tls", + start_tls=user_input[CONF_ENCRYPTION] == "starttls", + tls_context=( + client_context() + if user_input[CONF_VERIFY_SSL] + else client_context_no_verify() + ), + ): + pass + except SMTPTimeoutError: errors["base"] = "timeout_connect" except SMTPAuthenticationError: errors["base"] = "invalid_auth" - except SSLCertVerificationError: - errors["base"] = "invalid_cert" - except socket.gaierror, ConnectionRefusedError, SMTPException: + except SMTPException: errors["base"] = "cannot_connect" except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" - finally: - if mail is not None: - with suppress(SMTPException): - mail.quit() return errors @@ -362,11 +341,13 @@ async def async_step_user( """User flow to add a new recipient.""" if user_input is not None: - return self.async_create_entry( + result = self.async_create_entry( title=user_input.get(CONF_NAME, user_input[CONF_RECIPIENT]), data={}, unique_id=user_input[CONF_RECIPIENT], ) + self.hass.config_entries.async_schedule_reload(self._get_entry().entry_id) + return result return self.async_show_form( step_id="user", data_schema=vol.Schema( diff --git a/homeassistant/components/smtp/manifest.json b/homeassistant/components/smtp/manifest.json index ad47c06066a229..65a64dfc4e6ef9 100644 --- a/homeassistant/components/smtp/manifest.json +++ b/homeassistant/components/smtp/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smtp", "integration_type": "service", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "requirements": ["aiosmtplib==5.1.2"] } diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 112b4bf74d3ed3..71248c2923cb99 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -7,17 +7,11 @@ from email.mime.text import MIMEText import email.utils import logging -from smtplib import ( - SMTP, - SMTP_SSL, - SMTPAuthenticationError, - SMTPException, - SMTPServerDisconnected, -) -from socket import gaierror +from smtplib import SMTPException, SMTPServerDisconnected from ssl import SSLContext from typing import TYPE_CHECKING, Any, override +import aiosmtplib import voluptuous as vol from homeassistant.components.notify import ( @@ -92,6 +86,8 @@ PARALLEL_UPDATES = 1 +RETRIES = 2 + PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( { vol.Required(CONF_RECIPIENT): vol.All(cv.ensure_list, [vol.Email()]), @@ -198,7 +194,7 @@ def __init__( self, entry: SmtpConfigEntry, subentry: ConfigSubentry, - client: SmtpClient, + client: aiosmtplib.SMTP, ) -> None: """Initialize the notify entity.""" @@ -214,14 +210,14 @@ def __init__( self._attr_name = subentry.title @override - def send_message(self, message: str, title: str | None = None) -> None: + async def async_send_message(self, message: str, title: str | None = None) -> None: """Send an email message via notify.send_message action.""" msg = EmailMessage() msg.set_content(message) msg["Subject"] = title or ATTR_TITLE_DEFAULT - self._send_email(msg=msg) + await self._send_email(msg=msg) async def smtp_send_message( self, @@ -285,10 +281,10 @@ async def smtp_send_message( filename=target_filename, ) - await self.hass.async_add_executor_job(self._send_email, msg) + await self._send_email(msg) self._async_record_notification() - def _send_email(self, msg: EmailMessage) -> None: + async def _send_email(self, msg: EmailMessage) -> None: """Send the message.""" if TYPE_CHECKING: assert self._subentry.unique_id @@ -307,41 +303,25 @@ def _send_email(self, msg: EmailMessage) -> None: msg.add_header("Date", email.utils.format_datetime(dt_util.now())) msg.add_header("Message-Id", email.utils.make_msgid()) - client: SMTP_SSL | SMTP | None = None - for attempt in range(self._client.tries): + for attempt in range(RETRIES): try: - client = self._client.connect() - except SMTPAuthenticationError as e: + async with self._client as client: + await client.send_message(msg) + break + except aiosmtplib.SMTPAuthenticationError as e: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="authentication_error", ) from e - except (gaierror, ConnectionRefusedError, SMTPException) as e: - _LOGGER.debug("Full exception:", exc_info=True) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="send_mail_connection_error", - ) from e - - try: - client.sendmail( - self._entry.data[CONF_SENDER], - self._subentry.unique_id, - msg.as_string(), - ) - break - except SMTPException as e: + except aiosmtplib.SMTPException as e: _LOGGER.debug( "Error sending mail at attempt %s:", attempt + 1, exc_info=True ) - if attempt == self._client.tries - 1: + if attempt == RETRIES - 1: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="send_mail_connection_error", ) from e - finally: - with suppress(SMTPException): - client.quit() class MailNotificationService(SmtpClient, BaseNotificationService): diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index 0a428705cb66b5..d5dbfbcd3ec7d0 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -8,7 +8,6 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_cert": "Invalid certificate", "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, diff --git a/homeassistant/components/sofar/__init__.py b/homeassistant/components/sofar/__init__.py index 6499636d5621a7..3d3ae5dc2f0f04 100644 --- a/homeassistant/components/sofar/__init__.py +++ b/homeassistant/components/sofar/__init__.py @@ -7,13 +7,23 @@ from sofar_modbus.modern.device import SofarInverter, identify from homeassistant.components.modbus import async_get_unit +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SensorExtraStoredData, + SensorStateClass, +) from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + restore_state, +) from .const import CONF_UNIT_ID, DOMAIN, SCAN_INTERVAL, SETTINGS_SCAN_INTERVAL from .coordinator import SofarConfigEntry, SofarDataUpdateCoordinator, SofarRuntimeData +from .sensor import SENSOR_DESCRIPTIONS _LOGGER = logging.getLogger(__name__) @@ -34,6 +44,30 @@ async def _async_read_identity(entry: SofarConfigEntry, device: SofarInverter) - return +def _async_seed_high_water_marks( + hass: HomeAssistant, serial: str, device: SofarInverter +) -> None: + """Prime high-water marks before the first poll has nothing to compare.""" + registry = er.async_get(hass) + last_states = restore_state.async_get(hass).last_states + for description in SENSOR_DESCRIPTIONS: + if description.state_class is not SensorStateClass.TOTAL_INCREASING: + continue + entity_id = registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{serial}_{description.key}" + ) + if entity_id is None or (stored := last_states.get(entity_id)) is None: + continue + if stored.extra_data is None: + continue + extra = SensorExtraStoredData.from_dict(stored.extra_data.as_dict()) + if extra is None or not isinstance(extra.native_value, (int, float)): + continue + getattr(device, description.component).seed_high_water( + description.key, float(extra.native_value) + ) + + async def async_setup_entry(hass: HomeAssistant, entry: SofarConfigEntry) -> bool: """Set up Sofar Inverter Modbus from a config entry.""" serial = entry.unique_id @@ -59,6 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SofarConfigEntry) -> boo model=model, inverter_type=inverter_type, ) + _async_seed_high_water_marks(hass, serial, device) readings = SofarDataUpdateCoordinator( hass, diff --git a/homeassistant/components/solaredge_modbus/__init__.py b/homeassistant/components/solaredge_modbus/__init__.py index 48155d6a559589..1b5d5e3bfe73cc 100644 --- a/homeassistant/components/solaredge_modbus/__init__.py +++ b/homeassistant/components/solaredge_modbus/__init__.py @@ -40,7 +40,12 @@ from .entity import attachment_identity, inverter_device_info from .helpers import create_modbus_params -PLATFORMS = [Platform.BINARY_SENSOR, Platform.NUMBER, Platform.SENSOR] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, +] async def async_setup_entry( diff --git a/homeassistant/components/solaredge_modbus/icons.json b/homeassistant/components/solaredge_modbus/icons.json index 33959b00a866f4..f9a6ceec5827d8 100644 --- a/homeassistant/components/solaredge_modbus/icons.json +++ b/homeassistant/components/solaredge_modbus/icons.json @@ -31,6 +31,26 @@ "default": "mdi:transmission-tower-export" } }, + "select": { + "export_control_limit_type": { + "default": "mdi:scale-balance" + }, + "export_control_mode": { + "default": "mdi:transmission-tower-export" + }, + "storage_ac_charge_policy": { + "default": "mdi:battery-charging" + }, + "storage_command_mode": { + "default": "mdi:battery-sync-outline" + }, + "storage_control_mode": { + "default": "mdi:battery-sync" + }, + "storage_default_mode": { + "default": "mdi:battery-sync-outline" + } + }, "sensor": { "battery_status": { "default": "mdi:home-battery" diff --git a/homeassistant/components/solaredge_modbus/select.py b/homeassistant/components/solaredge_modbus/select.py new file mode 100644 index 00000000000000..bb8971d21bee81 --- /dev/null +++ b/homeassistant/components/solaredge_modbus/select.py @@ -0,0 +1,215 @@ +"""Support for SolarEdge Modbus select entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, override + +from solaredged import ( + ExportControl, + ExportControlLimit, + ExportControlMode, + SolarEdge, + StorageChargePolicy, + StorageControl, + StorageControlMode, + StorageMode, +) + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SolarEdgeModbusConfigEntry +from .entity import ControlComponent, SolarEdgeModbusControlEntity +from .helpers import solaredge_exception_handler + +PARALLEL_UPDATES = 1 + +# Export limiting has no dedicated "off" enum member; the library models a +# disabled limiter as mode None, exposed here as an explicit option. +EXPORT_MODE_DISABLED = "disabled" + + +@dataclass(frozen=True, kw_only=True) +class SolarEdgeModbusSelectEntityDescription[ComponentT](SelectEntityDescription): + """Describes a SolarEdge Modbus select entity.""" + + current_fn: Callable[[ComponentT], str | None] + # Options that depend on the detected layout, like meter presence. + options_fn: Callable[[SolarEdge], list[str]] | None = None + select_fn: Callable[[ComponentT, str], Awaitable[Any]] + + +STORAGE_SELECTS: tuple[SolarEdgeModbusSelectEntityDescription[StorageControl], ...] = ( + SolarEdgeModbusSelectEntityDescription( + key="storage_control_mode", + translation_key="storage_control_mode", + entity_category=EntityCategory.CONFIG, + options=[mode.name.lower() for mode in StorageControlMode], + current_fn=lambda storage: ( + storage.control_mode.name.lower() + if storage.control_mode is not None + else None + ), + select_fn=lambda storage, option: storage.set_control_mode( + StorageControlMode[option.upper()] + ), + ), + SolarEdgeModbusSelectEntityDescription( + key="storage_ac_charge_policy", + translation_key="storage_ac_charge_policy", + entity_category=EntityCategory.CONFIG, + options=[policy.name.lower() for policy in StorageChargePolicy], + current_fn=lambda storage: ( + storage.ac_charge_policy.name.lower() + if storage.ac_charge_policy is not None + else None + ), + select_fn=lambda storage, option: storage.set_ac_charge_policy( + StorageChargePolicy[option.upper()] + ), + ), + SolarEdgeModbusSelectEntityDescription( + key="storage_default_mode", + translation_key="storage_default_mode", + entity_category=EntityCategory.CONFIG, + options=[mode.name.lower() for mode in StorageMode], + current_fn=lambda storage: ( + storage.default_mode.name.lower() + if storage.default_mode is not None + else None + ), + select_fn=lambda storage, option: storage.set_default_mode( + StorageMode[option.upper()] + ), + ), + SolarEdgeModbusSelectEntityDescription( + key="storage_command_mode", + translation_key="storage_command_mode", + entity_category=EntityCategory.CONFIG, + options=[mode.name.lower() for mode in StorageMode], + current_fn=lambda storage: ( + storage.command_mode.name.lower() + if storage.command_mode is not None + else None + ), + select_fn=lambda storage, option: storage.set_command_mode( + StorageMode[option.upper()] + ), + ), +) + +EXPORT_SELECTS: tuple[SolarEdgeModbusSelectEntityDescription[ExportControl], ...] = ( + SolarEdgeModbusSelectEntityDescription( + key="export_control_mode", + translation_key="export_control_mode", + entity_category=EntityCategory.CONFIG, + # Limiting export by a meter reading needs a meter to read. + options_fn=lambda solaredge: [ + EXPORT_MODE_DISABLED, + *( + mode.name.lower() + for mode in ExportControlMode + if solaredge.meters or mode is ExportControlMode.PRODUCTION_CONTROL + ), + ], + current_fn=lambda export: ( + export.mode.name.lower() + if export.mode is not None + else EXPORT_MODE_DISABLED + ), + select_fn=lambda export, option: export.set_mode( + None + if option == EXPORT_MODE_DISABLED + else ExportControlMode[option.upper()] + ), + ), + SolarEdgeModbusSelectEntityDescription( + key="export_control_limit_type", + translation_key="export_control_limit_type", + entity_category=EntityCategory.CONFIG, + # Whether the site limit counts per phase or in total is part of the + # export-control setup the installer does, not day-to-day operation. + entity_registry_enabled_default=False, + options=[limit.name.lower() for limit in ExportControlLimit], + current_fn=lambda export: ( + export.limit_type.name.lower() if export.limit_type is not None else None + ), + select_fn=lambda export, option: export.write( + "limit_type", ExportControlLimit[option.upper()] + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SolarEdgeModbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SolarEdge Modbus select entities based on a config entry.""" + solaredge = entry.runtime_data.solaredge + + entities: list[SelectEntity] = [] + # The storage control block answers on inverters without storage too; the + # settings only mean something when a battery is actually attached. + if (storage := solaredge.storage_control) is not None and solaredge.batteries: + entities.extend( + SolarEdgeModbusSelectEntity( + entry=entry, description=description, component=storage + ) + for description in STORAGE_SELECTS + ) + if (export := solaredge.export_control) is not None: + entities.extend( + SolarEdgeModbusSelectEntity( + entry=entry, description=description, component=export + ) + for description in EXPORT_SELECTS + ) + + async_add_entities(entities) + + +class SolarEdgeModbusSelectEntity[ComponentT: ControlComponent]( + SolarEdgeModbusControlEntity[ComponentT], SelectEntity +): + """Defines a SolarEdge Modbus select entity.""" + + entity_description: SolarEdgeModbusSelectEntityDescription[ComponentT] + + @property + @override + def options(self) -> list[str]: + """Return the options this site can use, plus the one it is set to. + + A mode that needs hardware the site does not have is left out. The + exception is whatever the inverter is set to right now, which can be + anything an installer or the SolarEdge app left behind: the register is + the truth, and an entity may not report a state outside its options. + """ + description = self.entity_description + options = ( + description.options_fn(self.coordinator.solaredge) + if description.options_fn is not None + else super().options + ) + + current = self.current_option + if current is not None and current not in options: + return [*options, current] + + return options + + @property + @override + def current_option(self) -> str | None: + """Return the selected option.""" + return self.entity_description.current_fn(self._component) + + @solaredge_exception_handler + @override + async def async_select_option(self, option: str) -> None: + """Select an option.""" + await self.entity_description.select_fn(self._component, option) diff --git a/homeassistant/components/solaredge_modbus/strings.json b/homeassistant/components/solaredge_modbus/strings.json index 2c1ee90fcec1a7..c1d1e14712c54b 100644 --- a/homeassistant/components/solaredge_modbus/strings.json +++ b/homeassistant/components/solaredge_modbus/strings.json @@ -132,6 +132,67 @@ "name": "Site export limit" } }, + "select": { + "export_control_limit_type": { + "name": "Export limit type", + "state": { + "per_phase": "Per phase", + "total": "Total" + } + }, + "export_control_mode": { + "name": "Export limitation", + "state": { + "disabled": "[%key:common::state::disabled%]", + "export_control_consumption_meter": "Consumption meter", + "export_control_export_import_meter": "Export and import meter", + "production_control": "Production control" + } + }, + "storage_ac_charge_policy": { + "name": "Storage AC charge policy", + "state": { + "always": "Always", + "disabled": "[%key:common::state::disabled%]", + "fixed_energy_limit": "Fixed energy limit", + "percent_of_production": "Percentage of production" + } + }, + "storage_command_mode": { + "name": "Storage command mode", + "state": { + "charge_from_clipped_solar": "[%key:component::solaredge_modbus::entity::select::storage_default_mode::state::charge_from_clipped_solar%]", + "charge_from_solar": "[%key:component::solaredge_modbus::entity::select::storage_default_mode::state::charge_from_solar%]", + "charge_from_solar_and_grid": "[%key:component::solaredge_modbus::entity::select::storage_default_mode::state::charge_from_solar_and_grid%]", + "discharge_to_maximize_export": "[%key:component::solaredge_modbus::entity::select::storage_default_mode::state::discharge_to_maximize_export%]", + "discharge_to_minimize_import": "[%key:component::solaredge_modbus::entity::select::storage_default_mode::state::discharge_to_minimize_import%]", + "maximize_self_consumption": "[%key:component::solaredge_modbus::entity::select::storage_default_mode::state::maximize_self_consumption%]", + "solar_only": "[%key:component::solaredge_modbus::entity::select::storage_default_mode::state::solar_only%]" + } + }, + "storage_control_mode": { + "name": "Storage control mode", + "state": { + "backup_only": "Backup only", + "disabled": "[%key:common::state::disabled%]", + "maximize_self_consumption": "Maximize self-consumption", + "remote_control": "Remote control", + "time_of_use": "Time of use" + } + }, + "storage_default_mode": { + "name": "Storage default mode", + "state": { + "charge_from_clipped_solar": "Charge from clipped solar", + "charge_from_solar": "Charge from solar", + "charge_from_solar_and_grid": "Charge from solar and grid", + "discharge_to_maximize_export": "Discharge to maximize export", + "discharge_to_minimize_import": "Discharge to minimize import", + "maximize_self_consumption": "Maximize self-consumption", + "solar_only": "Solar only" + } + } + }, "sensor": { "battery_status": { "name": "Status", diff --git a/homeassistant/components/sunsynk/__init__.py b/homeassistant/components/sunsynk/__init__.py new file mode 100644 index 00000000000000..a40f9df399a23e --- /dev/null +++ b/homeassistant/components/sunsynk/__init__.py @@ -0,0 +1,59 @@ +"""The Sunsynk integration.""" + +import asyncio + +from sunsynk.client import SunsynkClient +from sunsynk.exceptions import SunsynkAuthenticationError, SunsynkConnectionError + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import SunsynkConfigEntry, SunsynkDataUpdateCoordinator +from .entity import inverter_device_info + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: SunsynkConfigEntry) -> bool: + """Set up Sunsynk from a config entry.""" + client = SunsynkClient( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + session=async_get_clientsession(hass), + ) + try: + inverters = await client.get_inverters() + except SunsynkAuthenticationError as err: + raise ConfigEntryAuthFailed(err) from err + except SunsynkConnectionError as err: + raise ConfigEntryNotReady(err) from err + + coordinators = [ + SunsynkDataUpdateCoordinator(hass, entry, client, inverter) + for inverter in inverters + ] + await asyncio.gather( + *( + coordinator.async_config_entry_first_refresh() + for coordinator in coordinators + ) + ) + entry.runtime_data = coordinators + + # The battery device links to its inverter, so the inverter must exist first. + device_registry = dr.async_get(hass) + for inverter in inverters: + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, **inverter_device_info(inverter) + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: SunsynkConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/sunsynk/config_flow.py b/homeassistant/components/sunsynk/config_flow.py new file mode 100644 index 00000000000000..29a1eec9a81137 --- /dev/null +++ b/homeassistant/components/sunsynk/config_flow.py @@ -0,0 +1,70 @@ +"""Config flow for the Sunsynk integration.""" + +from typing import Any, override + +from sunsynk.client import SunsynkClient +from sunsynk.exceptions import SunsynkAuthenticationError, SunsynkConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): TextSelector( + TextSelectorConfig(type=TextSelectorType.EMAIL, autocomplete="username") + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, autocomplete="current-password" + ) + ), + } +) + + +class SunsynkConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Sunsynk.""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + client = SunsynkClient( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) + try: + user = await client.get_user() + except SunsynkAuthenticationError: + errors["base"] = "invalid_auth" + except SunsynkConnectionError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(str(user.id)) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/sunsynk/const.py b/homeassistant/components/sunsynk/const.py new file mode 100644 index 00000000000000..5802989eb907f7 --- /dev/null +++ b/homeassistant/components/sunsynk/const.py @@ -0,0 +1,11 @@ +"""Constants for the Sunsynk integration.""" + +from datetime import timedelta +import logging +from typing import Final + +DOMAIN: Final = "sunsynk" +LOGGER = logging.getLogger(__package__) + +# The inverter uploads new data to the Sunsynk cloud every five minutes. +SCAN_INTERVAL = timedelta(minutes=5) diff --git a/homeassistant/components/sunsynk/coordinator.py b/homeassistant/components/sunsynk/coordinator.py new file mode 100644 index 00000000000000..bb449356771ca6 --- /dev/null +++ b/homeassistant/components/sunsynk/coordinator.py @@ -0,0 +1,73 @@ +"""Coordinator for the Sunsynk integration.""" + +import asyncio +from dataclasses import dataclass +from typing import override + +from sunsynk.battery import Battery +from sunsynk.client import SunsynkClient +from sunsynk.exceptions import SunsynkAuthenticationError, SunsynkConnectionError +from sunsynk.grid import Grid +from sunsynk.input import Input +from sunsynk.inverter import Inverter +from sunsynk.load import Load + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, SCAN_INTERVAL + +type SunsynkConfigEntry = ConfigEntry[list[SunsynkDataUpdateCoordinator]] + + +@dataclass +class SunsynkInverterData: + """Realtime data for one inverter.""" + + battery: Battery + grid: Grid + load: Load + solar: Input + + +class SunsynkDataUpdateCoordinator(DataUpdateCoordinator[SunsynkInverterData]): + """Fetch the realtime data of one Sunsynk inverter.""" + + config_entry: SunsynkConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: SunsynkConfigEntry, + client: SunsynkClient, + inverter: Inverter, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}_{inverter.sn}", + update_interval=SCAN_INTERVAL, + ) + self.client = client + self.inverter = inverter + + @override + async def _async_update_data(self) -> SunsynkInverterData: + """Fetch data from the Sunsynk API.""" + serial_number = self.inverter.sn + try: + battery, grid, load, solar = await asyncio.gather( + self.client.get_inverter_realtime_battery(serial_number), + self.client.get_inverter_realtime_grid(serial_number), + self.client.get_inverter_realtime_load(serial_number), + self.client.get_inverter_realtime_input(serial_number), + ) + except SunsynkAuthenticationError as err: + raise ConfigEntryAuthFailed(err) from err + except SunsynkConnectionError as err: + raise UpdateFailed(err) from err + return SunsynkInverterData(battery=battery, grid=grid, load=load, solar=solar) diff --git a/homeassistant/components/sunsynk/entity.py b/homeassistant/components/sunsynk/entity.py new file mode 100644 index 00000000000000..ad5b5936b29309 --- /dev/null +++ b/homeassistant/components/sunsynk/entity.py @@ -0,0 +1,70 @@ +"""Base entities for the Sunsynk integration.""" + +from sunsynk.inverter import Inverter + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import SunsynkDataUpdateCoordinator + + +def inverter_device_info(inverter: Inverter) -> DeviceInfo: + """Return the device info of an inverter.""" + name = f"Inverter {inverter.sn}" + if inverter.alias and inverter.alias != inverter.sn: + name = inverter.alias + return DeviceInfo( + identifiers={(DOMAIN, inverter.sn)}, + name=name, + manufacturer="Sunsynk", + model=inverter.model or None, + serial_number=inverter.sn, + sw_version=inverter.version.soft_ver if inverter.version else None, + ) + + +class SunsynkInverterEntity(CoordinatorEntity[SunsynkDataUpdateCoordinator]): + """An entity of a Sunsynk inverter.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: SunsynkDataUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.inverter.sn}_{description.key}" + self._attr_device_info = inverter_device_info(coordinator.inverter) + + +class SunsynkBatteryEntity(CoordinatorEntity[SunsynkDataUpdateCoordinator]): + """An entity of the battery of a Sunsynk inverter.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: SunsynkDataUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = description + serial_number = coordinator.inverter.sn + self._attr_unique_id = f"{serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{serial_number}_battery")}, + name=f"Battery {serial_number}", + manufacturer="Sunsynk", + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, serial_number), + config_entry_id=coordinator.config_entry.entry_id, + ), + ) diff --git a/homeassistant/components/sunsynk/icons.json b/homeassistant/components/sunsynk/icons.json new file mode 100644 index 00000000000000..39e1ac406cc6a5 --- /dev/null +++ b/homeassistant/components/sunsynk/icons.json @@ -0,0 +1,54 @@ +{ + "entity": { + "sensor": { + "charge_today": { + "default": "mdi:battery-arrow-up" + }, + "charge_total": { + "default": "mdi:battery-arrow-up" + }, + "discharge_today": { + "default": "mdi:battery-arrow-down" + }, + "discharge_total": { + "default": "mdi:battery-arrow-down" + }, + "grid_export_today": { + "default": "mdi:transmission-tower-export" + }, + "grid_export_total": { + "default": "mdi:transmission-tower-export" + }, + "grid_import_today": { + "default": "mdi:transmission-tower-import" + }, + "grid_import_total": { + "default": "mdi:transmission-tower-import" + }, + "grid_power": { + "default": "mdi:transmission-tower" + }, + "load_energy_today": { + "default": "mdi:home-lightning-bolt" + }, + "load_energy_total": { + "default": "mdi:home-lightning-bolt" + }, + "load_power": { + "default": "mdi:home-lightning-bolt" + }, + "power": { + "default": "mdi:home-battery" + }, + "solar_energy_today": { + "default": "mdi:solar-power-variant" + }, + "solar_energy_total": { + "default": "mdi:solar-power-variant" + }, + "solar_power": { + "default": "mdi:solar-power" + } + } + } +} diff --git a/homeassistant/components/sunsynk/manifest.json b/homeassistant/components/sunsynk/manifest.json new file mode 100644 index 00000000000000..af1ab3754cb81c --- /dev/null +++ b/homeassistant/components/sunsynk/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "sunsynk", + "name": "Sunsynk", + "codeowners": ["@jamesridgway"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sunsynk", + "integration_type": "hub", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["sunsynk-api-client==1.4.0"] +} diff --git a/homeassistant/components/sunsynk/quality_scale.yaml b/homeassistant/components/sunsynk/quality_scale.yaml new file mode 100644 index 00000000000000..464ec627dd7993 --- /dev/null +++ b/homeassistant/components/sunsynk/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide actions. + docs-conditions: + status: exempt + comment: This integration does not provide conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not provide triggers. + entity-event-setup: + status: exempt + comment: The entities do not subscribe to events. + 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: This integration does not provide actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration does not have an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: exempt + comment: The integration only reads data through a coordinator. + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: no mDNS and DHCP is a generic device. + discovery-update-info: + status: exempt + comment: The integration connects to the cloud. It does not store a local address. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: This integration does not have a case where a repair issue is needed. + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/sunsynk/sensor.py b/homeassistant/components/sunsynk/sensor.py new file mode 100644 index 00000000000000..d987cfe9cbc1cf --- /dev/null +++ b/homeassistant/components/sunsynk/sensor.py @@ -0,0 +1,262 @@ +"""Sensors for the Sunsynk integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import SunsynkConfigEntry, SunsynkInverterData +from .entity import SunsynkBatteryEntity, SunsynkInverterEntity + + +@dataclass(frozen=True, kw_only=True) +class SunsynkSensorEntityDescription(SensorEntityDescription): + """Describes a Sunsynk sensor entity.""" + + value_fn: Callable[[SunsynkInverterData], StateType] + + +SENSORS_INVERTER: tuple[SunsynkSensorEntityDescription, ...] = ( + SunsynkSensorEntityDescription( + key="solar_power", + translation_key="solar_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.solar.get_power(), + ), + SunsynkSensorEntityDescription( + key="solar_energy_today", + translation_key="solar_energy_today", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.solar.generated_today, + ), + SunsynkSensorEntityDescription( + key="solar_energy_total", + translation_key="solar_energy_total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.solar.generated_total, + ), + SunsynkSensorEntityDescription( + key="grid_power", + translation_key="grid_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.grid.get_total_power(), + ), + SunsynkSensorEntityDescription( + key="grid_frequency", + translation_key="grid_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.grid.fac, + ), + SunsynkSensorEntityDescription( + key="grid_import_today", + translation_key="grid_import_today", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.grid.today_import, + ), + SunsynkSensorEntityDescription( + key="grid_import_total", + translation_key="grid_import_total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.grid.total_import, + ), + SunsynkSensorEntityDescription( + key="grid_export_today", + translation_key="grid_export_today", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.grid.today_export, + ), + SunsynkSensorEntityDescription( + key="grid_export_total", + translation_key="grid_export_total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.grid.total_export, + ), + SunsynkSensorEntityDescription( + key="load_power", + translation_key="load_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.load.get_total_power(), + ), + SunsynkSensorEntityDescription( + key="load_energy_today", + translation_key="load_energy_today", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.load.daily_used, + ), + SunsynkSensorEntityDescription( + key="load_energy_total", + translation_key="load_energy_total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.load.total_used, + ), +) + +SENSORS_BATTERY: tuple[SunsynkSensorEntityDescription, ...] = ( + SunsynkSensorEntityDescription( + key="battery_power", + translation_key="power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.battery.power, + ), + SunsynkSensorEntityDescription( + key="battery_state_of_charge", + translation_key="state_of_charge", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.battery.soc, + ), + SunsynkSensorEntityDescription( + key="battery_voltage", + translation_key="voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.battery.voltage, + ), + SunsynkSensorEntityDescription( + key="battery_current", + translation_key="current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.battery.current, + ), + SunsynkSensorEntityDescription( + key="battery_temperature", + translation_key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.battery.temp, + ), + SunsynkSensorEntityDescription( + key="battery_charge_today", + translation_key="charge_today", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.battery.charge_today, + ), + SunsynkSensorEntityDescription( + key="battery_charge_total", + translation_key="charge_total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.battery.charge_total, + ), + SunsynkSensorEntityDescription( + key="battery_discharge_today", + translation_key="discharge_today", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.battery.discharge_today, + ), + SunsynkSensorEntityDescription( + key="battery_discharge_total", + translation_key="discharge_total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.battery.discharge_total, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SunsynkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Sunsynk sensors from a config entry.""" + entities: list[SensorEntity] = [] + for coordinator in entry.runtime_data: + entities.extend( + SunsynkInverterSensorEntity(coordinator, description) + for description in SENSORS_INVERTER + ) + if coordinator.data.battery.is_present: + entities.extend( + SunsynkBatterySensorEntity(coordinator, description) + for description in SENSORS_BATTERY + ) + async_add_entities(entities) + + +class SunsynkInverterSensorEntity(SunsynkInverterEntity, SensorEntity): + """A sensor of a Sunsynk inverter.""" + + entity_description: SunsynkSensorEntityDescription + + @property + @override + def native_value(self) -> StateType: + """Return the value of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) + + +class SunsynkBatterySensorEntity(SunsynkBatteryEntity, SensorEntity): + """A sensor of the battery of a Sunsynk inverter.""" + + entity_description: SunsynkSensorEntityDescription + + @property + @override + def native_value(self) -> StateType: + """Return the value of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/sunsynk/strings.json b/homeassistant/components/sunsynk/strings.json new file mode 100644 index 00000000000000..2f90b2a34a0dee --- /dev/null +++ b/homeassistant/components/sunsynk/strings.json @@ -0,0 +1,91 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "The password of your Sunsynk Connect account.", + "username": "The email address of your Sunsynk Connect account." + }, + "description": "Connect to your Sunsynk Connect account to get data from your inverters." + } + } + }, + "entity": { + "sensor": { + "charge_today": { + "name": "Charge today" + }, + "charge_total": { + "name": "Charge total" + }, + "current": { + "name": "Current" + }, + "discharge_today": { + "name": "Discharge today" + }, + "discharge_total": { + "name": "Discharge total" + }, + "grid_export_today": { + "name": "Grid export today" + }, + "grid_export_total": { + "name": "Grid export total" + }, + "grid_frequency": { + "name": "Grid frequency" + }, + "grid_import_today": { + "name": "Grid import today" + }, + "grid_import_total": { + "name": "Grid import total" + }, + "grid_power": { + "name": "Grid power" + }, + "load_energy_today": { + "name": "Load energy today" + }, + "load_energy_total": { + "name": "Load energy total" + }, + "load_power": { + "name": "Load power" + }, + "power": { + "name": "Power" + }, + "solar_energy_today": { + "name": "Solar energy today" + }, + "solar_energy_total": { + "name": "Solar energy total" + }, + "solar_power": { + "name": "Solar power" + }, + "state_of_charge": { + "name": "State of charge" + }, + "temperature": { + "name": "Temperature" + }, + "voltage": { + "name": "Voltage" + } + } + } +} diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index f0d1f671ae3791..ca0a37127d1b87 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -341,13 +341,6 @@ class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription): ), entity_registry_enabled_default=False, ), - TeslemetryBinarySensorEntityDescription( - key="passenger_seat_belt", - streaming_listener=lambda vehicle, callback: vehicle.listen_PassengerSeatBelt( - callback - ), - entity_registry_enabled_default=False, - ), TeslemetryBinarySensorEntityDescription( key="fast_charger_present", streaming_listener=lambda vehicle, callback: vehicle.listen_FastChargerPresent( diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 7741f8e3fa9d08..d6392a764885f2 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -168,9 +168,6 @@ "offroad_lightbar_present": { "name": "Offroad lightbar" }, - "passenger_seat_belt": { - "name": "Passenger seat belt" - }, "pin_to_drive_enabled": { "name": "PIN to Drive enabled" }, diff --git a/homeassistant/components/trafikverket_camera/coordinator.py b/homeassistant/components/trafikverket_camera/coordinator.py index 581aa6edd9924e..e4e3e66094f475 100644 --- a/homeassistant/components/trafikverket_camera/coordinator.py +++ b/homeassistant/components/trafikverket_camera/coordinator.py @@ -73,8 +73,10 @@ async def _async_update_data(self) -> CameraData: return CameraData(data=camera_data, image=None) image_url = camera_data.photourl - if camera_data.fullsizephoto: - image_url = f"{camera_data.photourl}?type=fullsize" + if camera_data.has_fullsizephoto: + if TYPE_CHECKING: + assert camera_data.photourlfullsize is not None + image_url = camera_data.photourlfullsize async with self.session.get( image_url, timeout=aiohttp.ClientTimeout(total=10) diff --git a/homeassistant/components/trafikverket_camera/manifest.json b/homeassistant/components/trafikverket_camera/manifest.json index 641654de20a0a0..0afecd2029944f 100644 --- a/homeassistant/components/trafikverket_camera/manifest.json +++ b/homeassistant/components/trafikverket_camera/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==1.1.1"] + "requirements": ["pytrafikverket==2.0.0"] } diff --git a/homeassistant/components/trafikverket_ferry/config_flow.py b/homeassistant/components/trafikverket_ferry/config_flow.py index d161374e0d21d6..a8cf72c2a76a3e 100644 --- a/homeassistant/components/trafikverket_ferry/config_flow.py +++ b/homeassistant/components/trafikverket_ferry/config_flow.py @@ -108,13 +108,13 @@ async def async_step_user( api_key: str = user_input[CONF_API_KEY] ferry_from: str = user_input[CONF_FROM] ferry_to: str = user_input.get(CONF_TO, "") - ferry_time: str = user_input[CONF_TIME] + ferry_time: str | None = user_input.get(CONF_TIME) weekdays: list[str] = user_input[CONF_WEEKDAY] name = f"{ferry_from}" if ferry_to: name = name + f" to {ferry_to}" - if ferry_time != "00:00:00": + if ferry_time and ferry_time != "00:00:00": name = name + f" at {ferry_time!s}" try: diff --git a/homeassistant/components/trafikverket_ferry/coordinator.py b/homeassistant/components/trafikverket_ferry/coordinator.py index dab9f7b5ced6dc..52f43ec5306f2d 100644 --- a/homeassistant/components/trafikverket_ferry/coordinator.py +++ b/homeassistant/components/trafikverket_ferry/coordinator.py @@ -64,7 +64,9 @@ def __init__(self, hass: HomeAssistant, config_entry: TVFerryConfigEntry) -> Non ) self._from: str = config_entry.data[CONF_FROM] self._to: str = config_entry.data[CONF_TO] - self._time: time | None = dt_util.parse_time(config_entry.data[CONF_TIME]) + self._time: time | None = None + if config_entry.data[CONF_TIME]: + self._time = dt_util.parse_time(config_entry.data[CONF_TIME]) self._weekdays: list[str] = config_entry.data[CONF_WEEKDAY] @override diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index a1c55f9697840b..83fb599e3bd9b9 100644 --- a/homeassistant/components/trafikverket_ferry/manifest.json +++ b/homeassistant/components/trafikverket_ferry/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==1.1.1"] + "requirements": ["pytrafikverket==2.0.0"] } diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index a97fd5b8cb8522..47a4f196e971a8 100644 --- a/homeassistant/components/trafikverket_train/manifest.json +++ b/homeassistant/components/trafikverket_train/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==1.1.1"] + "requirements": ["pytrafikverket==2.0.0"] } diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index c65bef540d41e9..3105db98ee35aa 100644 --- a/homeassistant/components/trafikverket_weatherstation/manifest.json +++ b/homeassistant/components/trafikverket_weatherstation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==1.1.1"] + "requirements": ["pytrafikverket==2.0.0"] } diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index eedeffea9378c7..931040dda1900b 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -1,5 +1,6 @@ """The ViCare integration.""" +from collections import defaultdict from contextlib import suppress import logging import os @@ -170,11 +171,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ViCareConfigEntry) -> bo ) as err: raise ConfigEntryAuthFailed("Authentication failed") from err - device_count = len(entry.runtime_data.devices) - coordinators: list[ViCareCoordinator] = [] + # Group devices by gateway: in viaGateway mode one bulk fetch refreshes + # every device behind a gateway, so one coordinator serves the gateway. + devices_by_gateway: dict[str, list[ViCareDevice]] = defaultdict(list) for device in entry.runtime_data.devices: - coordinator = ViCareCoordinator(hass, entry, device.api, device_count) - device.coordinator = coordinator + devices_by_gateway[device.config.getConfig().serial].append(device) + + gateway_count = len(devices_by_gateway) + coordinators: list[ViCareCoordinator] = [] + for gateway_devices in devices_by_gateway.values(): + representative = gateway_devices[0] + coordinator = ViCareCoordinator( + hass, + entry, + representative.api, + representative.config.getConfig(), + gateway_count, + ) + for device in gateway_devices: + device.coordinator = coordinator coordinators.append(coordinator) for device in entry.runtime_data.devices: @@ -233,20 +248,32 @@ def _setup_vicare_api( ) -> ViCareData: """Set up PyVicare API.""" client = PyViCare() + client.loadViaGateway(True) client.setCacheDuration(cache_duration) client.initWithExternalOAuth(auth) device_config_list = get_supported_devices(client.devices) - # increase cache duration to fit rate limit to number of devices - if (number_of_devices := len(device_config_list)) > 1: - cache_duration = DEFAULT_CACHE_DURATION * number_of_devices + # In viaGateway mode each gateway is one bulk fetch per cycle, so the rate + # limit scales with the number of gateways, not devices. Offline gateways + # are never fetched, and are skipped below, so they must not count here + # either; this has to match the grouping in async_setup_entry. + gateway_count = len( + { + config.getConfig().serial + for config in device_config_list + if config.isOnline() + } + ) + if gateway_count > 1: + cache_duration = DEFAULT_CACHE_DURATION * gateway_count _LOGGER.debug( - "Found %s devices, adjusting cache duration to %s", - number_of_devices, + "Found %s gateways, adjusting cache duration to %s", + gateway_count, cache_duration, ) client = PyViCare() + client.loadViaGateway(True) client.setCacheDuration(cache_duration) client.initWithExternalOAuth(auth) device_config_list = get_supported_devices(client.devices) diff --git a/homeassistant/components/vicare/coordinator.py b/homeassistant/components/vicare/coordinator.py index 3472c724afc82c..3661bf6e63bf86 100644 --- a/homeassistant/components/vicare/coordinator.py +++ b/homeassistant/components/vicare/coordinator.py @@ -5,11 +5,13 @@ from typing import override from PyViCare.PyViCareDevice import Device as PyViCareDevice +from PyViCare.PyViCareService import ViCareDeviceAccessor from PyViCare.PyViCareUtils import ( PyViCareDeviceCommunicationError, PyViCareInternalServerError, PyViCareInvalidCredentialsError, PyViCareInvalidDataError, + PyViCareNotSupportedFeatureError, PyViCareRateLimitError, ) import requests @@ -25,12 +27,12 @@ class ViCareCoordinator(DataUpdateCoordinator[None]): - """Coordinator for a single ViCare device. + """Coordinator for a single ViCare gateway. - Triggers a fresh fetch of the device's full feature payload into - PyViCare's internal cache so entity ``value_getter`` lambdas read - fresh data on each tick. Carries no payload of its own; freshness - is signalled via ``last_update_success``. + In viaGateway mode all devices behind a gateway share one service, so a + single feature fetch refreshes every device on that gateway. The fetch takes + the accessor of a representative device. Carries no payload of its own; + freshness is signalled via ``last_update_success``. """ config_entry: ViCareConfigEntry @@ -40,28 +42,35 @@ def __init__( hass: HomeAssistant, config_entry: ViCareConfigEntry, device: PyViCareDevice, - device_count: int, + accessor: ViCareDeviceAccessor, + gateway_count: int, ) -> None: - """Initialise the coordinator for one device.""" + """Initialise the coordinator for one gateway.""" super().__init__( hass, _LOGGER, config_entry=config_entry, - name=f"{DOMAIN}_{device.accessor.serial}_{device.accessor.device_id}", - update_interval=timedelta(seconds=DEFAULT_CACHE_DURATION * device_count), + name=f"{DOMAIN}_{accessor.serial}", + update_interval=timedelta(seconds=DEFAULT_CACHE_DURATION * gateway_count), ) self._device = device + self._accessor = accessor @override async def _async_update_data(self) -> None: - """Refresh the device's feature payload.""" + """Refresh the gateway's feature payload.""" await self.hass.async_add_executor_job(self._refresh) def _refresh(self) -> None: """Force a fresh fetch from the Viessmann API.""" try: self._device.service.clear_cache() - self._device.service.fetch_all_features(self._device.accessor) + self._device.service.fetch_all_features(self._accessor) + except PyViCareNotSupportedFeatureError: + # PACKAGE_NOT_PAID_FOR: load with no features instead of retrying setup. + _LOGGER.debug( + "No accessible features for gateway %s", self._accessor.serial + ) except PyViCareInvalidCredentialsError as err: raise ConfigEntryAuthFailed from err except ( diff --git a/homeassistant/components/vicare/diagnostics.py b/homeassistant/components/vicare/diagnostics.py index 008c533b430ba8..0bc0e4fd5c8426 100644 --- a/homeassistant/components/vicare/diagnostics.py +++ b/homeassistant/components/vicare/diagnostics.py @@ -3,6 +3,7 @@ import json from typing import Any +from PyViCare.PyViCareServiceViaGateway import filter_features_for_device from PyViCare.PyViCareUtils import PyViCareDeviceCommunicationError from homeassistant.components.diagnostics import async_redact_data @@ -36,7 +37,13 @@ def dump_devices() -> list[dict[str, Any]]: devices: list[dict[str, Any]] = [] for device in entry.runtime_data.client.all_devices: try: - devices.append(json.loads(device.dump_secure())) + dump = json.loads(device.dump_secure()) + # In viaGateway mode dump_secure() returns the whole gateway's + # features, so scope them to the device the entry describes. + dump["data"] = filter_features_for_device( + dump["data"], device.device_id + ) + devices.append(dump) except PyViCareDeviceCommunicationError as err: # One offline gateway must not abort the whole diagnostics dump. devices.append( diff --git a/homeassistant/components/vistapool/coordinator.py b/homeassistant/components/vistapool/coordinator.py index ad24ebf636bf07..6a59cc11dcfbd7 100644 --- a/homeassistant/components/vistapool/coordinator.py +++ b/homeassistant/components/vistapool/coordinator.py @@ -125,13 +125,23 @@ def apply_optimistic(self, value_path: str, value: Any) -> None: coordinator.data after a successful REST call gives entities instant feedback; the next snapshot from Firestore overwrites it harmlessly. """ - keys = value_path.split(".") - target: dict[str, Any] = self.data - for key in keys[:-1]: - child = target.get(key) - if not isinstance(child, dict): - child = {} - target[key] = child - target = child - target[keys[-1]] = value + self.apply_optimistic_values({value_path: value}) + + def apply_optimistic_values(self, updates: dict[str, Any]) -> None: + """Reflect several just-written values as a single update. + + Applying them one at a time would publish a state where only part + of the write has landed, which entities derived from more than one + path briefly read as a different value. + """ + for value_path, value in updates.items(): + keys = value_path.split(".") + target: dict[str, Any] = self.data + for key in keys[:-1]: + child = target.get(key) + if not isinstance(child, dict): + child = {} + target[key] = child + target = child + target[keys[-1]] = value self.async_set_updated_data(self.data) diff --git a/homeassistant/components/vistapool/icons.json b/homeassistant/components/vistapool/icons.json index 4746854e154117..ac31757b4b2849 100644 --- a/homeassistant/components/vistapool/icons.json +++ b/homeassistant/components/vistapool/icons.json @@ -1,5 +1,13 @@ { "entity": { + "select": { + "light_mode": { + "default": "mdi:lightbulb-auto" + }, + "light_schedule_frequency": { + "default": "mdi:calendar-refresh" + } + }, "sensor": { "chlorine": { "default": "mdi:gauge" @@ -29,6 +37,12 @@ }, "filtration_interval_start": { "default": "mdi:clock-start" + }, + "light_schedule_end": { + "default": "mdi:clock-end" + }, + "light_schedule_start": { + "default": "mdi:clock-start" } } } diff --git a/homeassistant/components/vistapool/number.py b/homeassistant/components/vistapool/number.py index 0e4fa5787625c1..d7230f916132e6 100644 --- a/homeassistant/components/vistapool/number.py +++ b/homeassistant/components/vistapool/number.py @@ -57,6 +57,7 @@ def _max_electrolysis(coordinator: VistapoolDataUpdateCoordinator) -> float: VistapoolNumberEntityDescription( key="redox_setpoint", translation_key="redox_setpoint", + device_class=NumberDeviceClass.VOLTAGE, entity_category=EntityCategory.CONFIG, native_min_value=500, native_max_value=800, diff --git a/homeassistant/components/vistapool/quality_scale.yaml b/homeassistant/components/vistapool/quality_scale.yaml index 96224b9b8f9dd7..eaf772604f1586 100644 --- a/homeassistant/components/vistapool/quality_scale.yaml +++ b/homeassistant/components/vistapool/quality_scale.yaml @@ -60,7 +60,13 @@ rules: docs-supported-functions: done docs-use-cases: done dynamic-devices: done - entity-device-class: todo + entity-device-class: + status: done + comment: >- + All entities use a device class where Home Assistant offers a matching + one. Chlorine, UV and the electrolysis production rate (g/h) have no + applicable class; the conductivity reading is reported without a unit, + so it cannot be labelled as a conductivity measurement. entity-translations: done exception-translations: done icon-translations: done diff --git a/homeassistant/components/vistapool/select.py b/homeassistant/components/vistapool/select.py index f37d62573c6e36..b7101cbcef65e9 100644 --- a/homeassistant/components/vistapool/select.py +++ b/homeassistant/components/vistapool/select.py @@ -22,13 +22,31 @@ _PUMP_MODE_OPTIONS = ["manual", "auto", "heat", "smart", "intel"] _PUMP_SPEED_OPTIONS = ["slow", "medium", "high"] +_LIGHT_FREQUENCIES = {"daily": 86400, "weekly": 604800} +_LIGHT_MODE_PATH = "light.mode" +_LIGHT_STATUS_PATH = "light.status" + +# Off and on leave schedule mode; auto only re-arms it and lets the +# controller's own schedule drive light.status. Each option must land as one +# command, or the controller sees a half-applied state. +_LIGHT_MODE_UPDATES: dict[str, dict[str, int]] = { + "off": {_LIGHT_MODE_PATH: 0, _LIGHT_STATUS_PATH: 0}, + "on": {_LIGHT_MODE_PATH: 0, _LIGHT_STATUS_PATH: 1}, + "auto": {_LIGHT_MODE_PATH: 1}, +} + @dataclass(frozen=True, kw_only=True) class VistapoolSelectEntityDescription(SelectEntityDescription): """Describes a Vistapool select entity.""" value_path: str + # A capability flag that must be set, such as main.hasPH. exists_path: str | tuple[str, ...] | None = None + # A field the controller only reports when it supports the feature. Unlike + # exists_path this is a presence check, so a valid zero still counts. + presence_path: str | None = None + value_map: dict[str, int] | None = None SELECT_DESCRIPTIONS: tuple[VistapoolSelectEntityDescription, ...] = ( @@ -57,6 +75,15 @@ class VistapoolSelectEntityDescription(SelectEntityDescription): ) for i in (1, 2, 3) ), + VistapoolSelectEntityDescription( + key="light_schedule_frequency", + translation_key="light_schedule_frequency", + entity_category=EntityCategory.CONFIG, + options=list(_LIGHT_FREQUENCIES), + value_path="light.freq", + presence_path="light.freq", + value_map=_LIGHT_FREQUENCIES, + ), ) @@ -74,7 +101,14 @@ def _build_select_entities( ) if not all(coordinator.get_value(path) for path in required): continue + if ( + description.presence_path is not None + and coordinator.get_value(description.presence_path) is None + ): + continue entities.append(VistapoolSelect(coordinator, description)) + if coordinator.get_value(_LIGHT_MODE_PATH) is not None: + entities.append(VistapoolLightModeSelect(coordinator)) return entities @@ -129,24 +163,31 @@ def __init__( @override def current_option(self) -> str | None: """Return the option that maps to the current API value.""" - index = _to_index( - self.coordinator.get_value(self.entity_description.value_path) - ) + raw = _to_index(self.coordinator.get_value(self.entity_description.value_path)) + if raw is None: + return None + if (value_map := self.entity_description.value_map) is not None: + return next( + (option for option, value in value_map.items() if value == raw), None + ) options = self.entity_description.options or [] - if index is None or not 0 <= index < len(options): + if not 0 <= raw < len(options): return None - return options[index] + return options[raw] @override async def async_select_option(self, option: str) -> None: - """Send the index of the chosen option to the controller.""" - assert self.entity_description.options is not None - index = self.entity_description.options.index(option) + """Send the chosen option to the controller.""" + if (value_map := self.entity_description.value_map) is not None: + value = value_map[option] + else: + assert self.entity_description.options is not None + value = self.entity_description.options.index(option) try: await self.coordinator.api.set_value( self.coordinator.pool_id, self.entity_description.value_path, - index, + value, ) except AquariteError as err: raise HomeAssistantError( @@ -154,3 +195,49 @@ async def async_select_option(self, option: str) -> None: translation_key="set_failed", translation_placeholders={"entity": self.entity_id}, ) from err + self.coordinator.apply_optimistic(self.entity_description.value_path, value) + + +class VistapoolLightModeSelect(VistapoolEntity, SelectEntity): + """Pool light mode: off, on, or the controller's own schedule. + + Off and on need light.mode and light.status written together, so this + writes through set_values rather than the single-value helper. + """ + + _attr_translation_key = "light_mode" + _attr_entity_category = EntityCategory.CONFIG + _attr_options = list(_LIGHT_MODE_UPDATES) + + def __init__(self, coordinator: VistapoolDataUpdateCoordinator) -> None: + """Initialize the light mode select entity.""" + super().__init__(coordinator) + self._attr_unique_id = self.build_unique_id("light_mode") + + @property + @override + def current_option(self) -> str | None: + """Return auto while the schedule is armed, else the on/off state.""" + mode = _to_index(self.coordinator.get_value(_LIGHT_MODE_PATH)) + if mode is None: + return None + if mode == 1: + return "auto" + status = _to_index(self.coordinator.get_value(_LIGHT_STATUS_PATH)) + if status is None: + return None + return "on" if status == 1 else "off" + + @override + async def async_select_option(self, option: str) -> None: + """Send the option's field set to the controller as one command.""" + updates = _LIGHT_MODE_UPDATES[option] + try: + await self.coordinator.api.set_values(self.coordinator.pool_id, updates) + except AquariteError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_failed", + translation_placeholders={"entity": self.entity_id}, + ) from err + self.coordinator.apply_optimistic_values(updates) diff --git a/homeassistant/components/vistapool/sensor.py b/homeassistant/components/vistapool/sensor.py index 5ecb8a37622f74..9289d8b020f4af 100644 --- a/homeassistant/components/vistapool/sensor.py +++ b/homeassistant/components/vistapool/sensor.py @@ -89,6 +89,7 @@ class VistapoolSensorEntityDescription(SensorEntityDescription): VistapoolSensorEntityDescription( key="redox_potential", translation_key="redox_potential", + device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, state_class=SensorStateClass.MEASUREMENT, value_path="modules.rx.current", diff --git a/homeassistant/components/vistapool/strings.json b/homeassistant/components/vistapool/strings.json index 16fb5fa407af43..8d829e4abafd21 100644 --- a/homeassistant/components/vistapool/strings.json +++ b/homeassistant/components/vistapool/strings.json @@ -163,6 +163,21 @@ "slow": "[%key:component::vistapool::entity::select::pump_speed::state::slow%]" } }, + "light_mode": { + "name": "Light mode", + "state": { + "auto": "Auto", + "off": "Off", + "on": "On" + } + }, + "light_schedule_frequency": { + "name": "Light schedule frequency", + "state": { + "daily": "Daily", + "weekly": "Weekly" + } + }, "pump_mode": { "name": "Pump mode", "state": { @@ -234,6 +249,12 @@ }, "filtration_interval_start": { "name": "Filtration interval {number} start" + }, + "light_schedule_end": { + "name": "Light schedule end" + }, + "light_schedule_start": { + "name": "Light schedule start" } } }, diff --git a/homeassistant/components/vistapool/switch.py b/homeassistant/components/vistapool/switch.py index 333603c3cb2538..f2fc99bb8963ed 100644 --- a/homeassistant/components/vistapool/switch.py +++ b/homeassistant/components/vistapool/switch.py @@ -5,7 +5,11 @@ from aioaquarite import AquariteError -from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -114,6 +118,8 @@ def _async_add_pool(coordinator: VistapoolDataUpdateCoordinator) -> None: class VistapoolSwitch(VistapoolEntity, SwitchEntity): """Generic Vistapool switch driven by an entity description.""" + _attr_device_class = SwitchDeviceClass.SWITCH + entity_description: VistapoolSwitchEntityDescription def __init__( diff --git a/homeassistant/components/vistapool/time.py b/homeassistant/components/vistapool/time.py index a3ada79de863f2..f0da17b8c07e03 100644 --- a/homeassistant/components/vistapool/time.py +++ b/homeassistant/components/vistapool/time.py @@ -29,18 +29,32 @@ class VistapoolTimeEntityDescription(TimeEntityDescription): """Describes a Vistapool time entity.""" value_path: str - - -TIME_DESCRIPTIONS: tuple[VistapoolTimeEntityDescription, ...] = tuple( - VistapoolTimeEntityDescription( - key=f"filtration_interval_{interval}_{bound}", - translation_key=f"filtration_interval_{bound}", - translation_placeholders={"number": str(interval)}, - entity_category=EntityCategory.CONFIG, - value_path=f"filtration.interval{interval}.{api_field}", - ) - for interval in (1, 2, 3) - for bound, api_field in (("start", "from"), ("end", "to")) + # A field the controller only reports when it supports the feature. + presence_path: str | None = None + + +TIME_DESCRIPTIONS: tuple[VistapoolTimeEntityDescription, ...] = ( + *( + VistapoolTimeEntityDescription( + key=f"filtration_interval_{interval}_{bound}", + translation_key=f"filtration_interval_{bound}", + translation_placeholders={"number": str(interval)}, + entity_category=EntityCategory.CONFIG, + value_path=f"filtration.interval{interval}.{api_field}", + ) + for interval in (1, 2, 3) + for bound, api_field in (("start", "from"), ("end", "to")) + ), + *( + VistapoolTimeEntityDescription( + key=f"light_schedule_{bound}", + translation_key=f"light_schedule_{bound}", + entity_category=EntityCategory.CONFIG, + value_path=f"light.{api_field}", + presence_path=f"light.{api_field}", + ) + for bound, api_field in (("start", "from"), ("end", "to")) + ), ) @@ -49,7 +63,10 @@ def _build_time_entities( ) -> list[TimeEntity]: """Build the time entities for a single pool.""" return [ - VistapoolTime(coordinator, description) for description in TIME_DESCRIPTIONS + VistapoolTime(coordinator, description) + for description in TIME_DESCRIPTIONS + if description.presence_path is None + or coordinator.get_value(description.presence_path) is not None ] diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2b010508cc0772..72322aeb918da9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -399,6 +399,7 @@ "jewish_calendar", "justnimbus", "jvc_projector", + "kaco_modbus", "kaleidescape", "karakeep", "keenetic_ndms2", @@ -765,6 +766,7 @@ "suez_water", "sun", "sunricher_dali", + "sunsynk", "sunweg", "surepetcare", "swiss_public_transport", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 79a286946fcffe..3ce24b731a4c22 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3582,6 +3582,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "kaco_modbus": { + "name": "KACO Modbus", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "kaiser_nienhaus": { "name": "Kaiser Nienhaus", "integration_type": "virtual", @@ -7069,6 +7075,12 @@ "config_flow": true, "iot_class": "local_push" }, + "sunsynk": { + "name": "Sunsynk", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "sunweg": { "name": "Sun WEG", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 02b32b2f882659..a235a9ef7e8311 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3058,6 +3058,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.kaco_modbus.*] +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.kaleidescape.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -5420,6 +5430,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.sunsynk.*] +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.surepetcare.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 1527deb78bde99..a70979ecfbf62f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -434,6 +434,9 @@ aioskybell==22.7.0 # homeassistant.components.slimproto aioslimproto==3.0.0 +# homeassistant.components.smtp +aiosmtplib==5.1.2 + # homeassistant.components.solaredge aiosolaredge==1.0.2 @@ -914,7 +917,7 @@ electrickiwi-api==0.9.14 elevenlabs==2.51.0 # homeassistant.components.elgato -elgato==6.0.0 +elgato==6.1.0 # homeassistant.components.elkm1 elkm1-lib==2.2.15 @@ -1315,7 +1318,7 @@ homekit-audio-proxy==1.2.1 homelink-integration-api==0.0.5 # homeassistant.components.homematicip_cloud -homematicip==2.15.0 +homematicip==2.16.0 # homeassistant.components.homevolt homevolt==0.5.0 @@ -1441,6 +1444,9 @@ jsonpath-python==1.1.6 # homeassistant.components.justnimbus justnimbus==0.7.4 +# homeassistant.components.kaco_modbus +kaco-modbus==1.1.0 + # homeassistant.components.kaiterra kaiterra-async-client==1.1.0 @@ -2851,7 +2857,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==1.1.1 +pytrafikverket==2.0.0 # homeassistant.components.v2c pytrydan==1.0.5 @@ -3173,6 +3179,9 @@ streamlabswater==1.0.1 # homeassistant.components.subaru subarulink==0.7.19 +# homeassistant.components.sunsynk +sunsynk-api-client==1.4.0 + # homeassistant.components.surepetcare surepy==0.9.0 diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 10d8d33a40e9c1..cdd8a4fe22b250 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -263,7 +263,6 @@ class Rule: "dlink", "dlna_dmr", "dlna_dms", - "dnsip", "dominos", "doods", "doorbird", @@ -1192,7 +1191,6 @@ class Rule: "dlink", "dlna_dmr", "dlna_dms", - "dnsip", "dominos", "doods", "doorbird", diff --git a/tests/components/aemet/test_config_flow.py b/tests/components/aemet/test_config_flow.py index 3dd8303c8cb1e7..8d57c216cb7482 100644 --- a/tests/components/aemet/test_config_flow.py +++ b/tests/components/aemet/test_config_flow.py @@ -136,7 +136,15 @@ async def test_form_duplicated_id( entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG, ) assert result["type"] is FlowResultType.ABORT @@ -153,7 +161,15 @@ async def test_form_auth_error(hass: HomeAssistant) -> None: return_value=mocked_aemet, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG, ) assert result["errors"] == {"base": "invalid_api_key"} diff --git a/tests/components/agent_dvr/test_config_flow.py b/tests/components/agent_dvr/test_config_flow.py index 88332b833a676c..4b935126d8258f 100644 --- a/tests/components/agent_dvr/test_config_flow.py +++ b/tests/components/agent_dvr/test_config_flow.py @@ -34,9 +34,15 @@ async def test_user_device_exists_abort( await init_integration(hass, aioclient_mock) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: "example.local", CONF_PORT: 8090}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "example.local", CONF_PORT: 8090}, ) assert result["type"] is FlowResultType.ABORT @@ -50,9 +56,15 @@ async def test_connection_error( aioclient_mock.get("http://example.local:8090/command.cgi?cmd=getStatus", text="") result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: "example.local", CONF_PORT: 8090}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "example.local", CONF_PORT: 8090}, ) assert result["errors"]["base"] == "cannot_connect" diff --git a/tests/components/airzone/test_config_flow.py b/tests/components/airzone/test_config_flow.py index 376e948837b41f..692b47b2e05d5a 100644 --- a/tests/components/airzone/test_config_flow.py +++ b/tests/components/airzone/test_config_flow.py @@ -130,7 +130,15 @@ async def test_form_invalid_system_id(hass: HomeAssistant) -> None: ), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, ) assert result["type"] is FlowResultType.FORM @@ -177,7 +185,15 @@ async def test_form_duplicated_id(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, ) assert result["type"] is FlowResultType.ABORT @@ -192,7 +208,15 @@ async def test_connection_error(hass: HomeAssistant) -> None: side_effect=AirzoneError, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, ) assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/airzone_cloud/test_config_flow.py b/tests/components/airzone_cloud/test_config_flow.py index 04e253eb494718..822523ceef684e 100644 --- a/tests/components/airzone_cloud/test_config_flow.py +++ b/tests/components/airzone_cloud/test_config_flow.py @@ -154,9 +154,15 @@ async def test_login_error(hass: HomeAssistant) -> None: side_effect=LoginError, ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_USERNAME: CONFIG[CONF_USERNAME], CONF_PASSWORD: CONFIG[CONF_PASSWORD], }, diff --git a/tests/components/amberelectric/test_config_flow.py b/tests/components/amberelectric/test_config_flow.py index bffd064cfc02d6..358025282d83f4 100644 --- a/tests/components/amberelectric/test_config_flow.py +++ b/tests/components/amberelectric/test_config_flow.py @@ -160,10 +160,9 @@ async def test_single_pending_site( assert initial_result.get("step_id") == "user" # Test filling in API key - enter_api_key_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_API_TOKEN: API_KEY}, + enter_api_key_result = await hass.config_entries.flow.async_configure( + initial_result["flow_id"], + user_input={CONF_API_TOKEN: API_KEY}, ) assert enter_api_key_result.get("type") is FlowResultType.FORM assert enter_api_key_result.get("step_id") == "site" @@ -191,10 +190,9 @@ async def test_single_site(hass: HomeAssistant, single_site_api: Mock) -> None: assert initial_result.get("step_id") == "user" # Test filling in API key - enter_api_key_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_API_TOKEN: API_KEY}, + enter_api_key_result = await hass.config_entries.flow.async_configure( + initial_result["flow_id"], + user_input={CONF_API_TOKEN: API_KEY}, ) assert enter_api_key_result.get("type") is FlowResultType.FORM assert enter_api_key_result.get("step_id") == "site" @@ -218,9 +216,15 @@ async def test_single_closed_site_no_closed_date( ) -> None: """Test single closed site with no closed date is filtered out.""" enter_api_key_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_API_TOKEN: API_KEY}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert enter_api_key_result["type"] is FlowResultType.FORM + assert enter_api_key_result["step_id"] == "user" + + enter_api_key_result = await hass.config_entries.flow.async_configure( + enter_api_key_result["flow_id"], + user_input={CONF_API_TOKEN: API_KEY}, ) assert enter_api_key_result.get("type") is FlowResultType.FORM assert enter_api_key_result.get("step_id") == "user" @@ -238,10 +242,9 @@ async def test_single_site_rejoin( assert initial_result.get("step_id") == "user" # Test filling in API key - enter_api_key_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_API_TOKEN: API_KEY}, + enter_api_key_result = await hass.config_entries.flow.async_configure( + initial_result["flow_id"], + user_input={CONF_API_TOKEN: API_KEY}, ) assert enter_api_key_result.get("type") is FlowResultType.FORM assert enter_api_key_result.get("step_id") == "site" @@ -263,9 +266,15 @@ async def test_single_site_rejoin( async def test_no_site(hass: HomeAssistant, no_site_api: Mock) -> None: """Test no site.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_API_TOKEN: "psk_123456789"}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_TOKEN: "psk_123456789"}, ) assert result.get("type") is FlowResultType.FORM @@ -283,10 +292,9 @@ async def test_invalid_key(hass: HomeAssistant, invalid_key_api: Mock) -> None: assert result.get("step_id") == "user" # Test filling in API key - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_API_TOKEN: "psk_123456789"}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_TOKEN: "psk_123456789"}, ) assert result.get("type") is FlowResultType.FORM # Goes back to the user step @@ -303,10 +311,9 @@ async def test_unknown_error(hass: HomeAssistant, api_error: Mock) -> None: assert result.get("step_id") == "user" # Test filling in API key - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_API_TOKEN: "psk_123456789"}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_TOKEN: "psk_123456789"}, ) assert result.get("type") is FlowResultType.FORM # Goes back to the user step diff --git a/tests/components/androidtv/test_config_flow.py b/tests/components/androidtv/test_config_flow.py index 2c8970081aa57c..79a7c0a37f6357 100644 --- a/tests/components/androidtv/test_config_flow.py +++ b/tests/components/androidtv/test_config_flow.py @@ -163,9 +163,15 @@ async def test_user_adbkey(hass: HomeAssistant) -> None: PATCH_SETUP_ENTRY as mock_setup_entry, ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=flow_input, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=flow_input, ) await hass.async_block_till_done() @@ -189,9 +195,15 @@ async def test_error_both_key_server(hass: HomeAssistant) -> None: }, } result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=flow_input, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=flow_input, ) assert result["type"] is FlowResultType.FORM @@ -221,9 +233,15 @@ async def test_error_invalid_key(hass: HomeAssistant) -> None: CONF_MORE_OPTIONS: {CONF_ADBKEY: ADBKEY}, } result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=flow_input, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=flow_input, ) assert result["type"] is FlowResultType.FORM @@ -269,9 +287,15 @@ async def test_invalid_mac( return_value=(MockConfigDevice(eth_mac, wifi_mac), None), ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=flow_input, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=flow_input, ) assert result["type"] is FlowResultType.ABORT @@ -284,12 +308,17 @@ async def test_abort_if_host_exist(hass: HomeAssistant) -> None: domain=DOMAIN, data=CONFIG_ADB_SERVER, unique_id=ETH_MAC ).add_to_hass(hass) - config_data = CONFIG_PYTHON_ADB # Should fail, same HOST result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=config_data, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=FLOW_PYTHON_ADB, ) assert result["type"] is FlowResultType.ABORT @@ -310,9 +339,15 @@ async def test_abort_if_unique_exist(hass: HomeAssistant) -> None: return_value=(MockConfigDevice(), None), ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONFIG_ADB_SERVER, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=FLOW_ADB_SERVER, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/anthemav/test_config_flow.py b/tests/components/anthemav/test_config_flow.py index ee2f1da00e9942..ed27c6998c0d4f 100644 --- a/tests/components/anthemav/test_config_flow.py +++ b/tests/components/anthemav/test_config_flow.py @@ -109,7 +109,15 @@ async def test_device_already_configured( mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=config + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config, ) assert result.get("type") is FlowResultType.ABORT diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index d2b52951b52b2d..5b4139bea04935 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -29,7 +29,15 @@ async def test_config_flow_cannot_connect( mock_request_status.side_effect = exception result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, ) assert result["type"] is FlowResultType.FORM assert result["errors"]["base"] == "cannot_connect" @@ -48,7 +56,15 @@ async def test_config_flow_duplicate_host_port( # the entry already exists. mock_request_status.return_value = MOCK_STATUS result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -60,9 +76,15 @@ async def test_config_flow_duplicate_host_port( "SERIALNO": MOCK_STATUS["SERIALNO"] + "ZZZ" } result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=another_host, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=another_host, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == another_host @@ -83,9 +105,15 @@ async def test_config_flow_duplicate_serial_number( mock_request_status.return_value = MOCK_STATUS another_host = CONF_DATA | {CONF_HOST: "another_host"} result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=another_host, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=another_host, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -95,7 +123,15 @@ async def test_config_flow_duplicate_serial_number( "SERIALNO": MOCK_STATUS["SERIALNO"] + "ZZZ" } result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=another_host + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=another_host, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == another_host @@ -152,7 +188,15 @@ async def test_flow_minimal_status( integration will vary. """ result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/apsystems/test_config_flow.py b/tests/components/apsystems/test_config_flow.py index 94771fa2d1d0e2..a48c91db78dab5 100644 --- a/tests/components/apsystems/test_config_flow.py +++ b/tests/components/apsystems/test_config_flow.py @@ -19,9 +19,15 @@ async def test_form_create_success( ) -> None: """Test we handle creatinw with success.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_IP_ADDRESS: "127.0.0.1", }, ) @@ -36,9 +42,15 @@ async def test_form_create_success_custom_port( ) -> None: """Test we handle creating with custom port with success.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_IP_ADDRESS: "127.0.0.1", CONF_PORT: 8042, }, @@ -57,9 +69,15 @@ async def test_form_cannot_connect_and_recover( mock_apsystems.get_device_info.side_effect = TimeoutError result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_IP_ADDRESS: "127.0.0.2", }, ) @@ -88,9 +106,15 @@ async def test_form_cannot_connect_and_recover_custom_port( mock_apsystems.get_device_info.side_effect = TimeoutError result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_IP_ADDRESS: "127.0.0.2", CONF_PORT: 8042}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_IP_ADDRESS: "127.0.0.2", CONF_PORT: 8042}, ) assert result["type"] is FlowResultType.FORM @@ -116,9 +140,15 @@ async def test_form_unique_id_already_configured( mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_IP_ADDRESS: "127.0.0.2", }, ) diff --git a/tests/components/asuswrt/test_config_flow.py b/tests/components/asuswrt/test_config_flow.py index 3660d8797114a2..bf88f0c5a50a68 100644 --- a/tests/components/asuswrt/test_config_flow.py +++ b/tests/components/asuswrt/test_config_flow.py @@ -166,9 +166,15 @@ async def test_error_pwd_required(hass: HomeAssistant, config) -> None: """Test we abort for missing password.""" config_data = {k: v for k, v in config.items() if k != CONF_PASSWORD} result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=config_data, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_data, ) assert result["type"] is FlowResultType.FORM @@ -179,9 +185,15 @@ async def test_error_no_password_ssh(hass: HomeAssistant) -> None: """Test we abort for wrong password and ssh file combination.""" config_data = {k: v for k, v in CONFIG_SCHEMA_SSH.items() if k != CONF_PASSWORD} result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=config_data, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_data, ) assert result["type"] is FlowResultType.FORM @@ -192,9 +204,15 @@ async def test_error_password_and_ssh(hass: HomeAssistant) -> None: """Test we abort for both password and ssh file combination.""" config_data = {**CONFIG_SCHEMA_SSH, CONF_MORE_OPTIONS: {CONF_SSH_KEY: SSH_KEY}} result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=config_data, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_data, ) assert result["type"] is FlowResultType.FORM @@ -213,9 +231,15 @@ def mock_is_file(file) -> bool: patch_is_file.side_effect = mock_is_file result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=config_data, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_data, ) assert result["type"] is FlowResultType.FORM @@ -226,9 +250,15 @@ async def test_error_invalid_host(hass: HomeAssistant, patch_get_host) -> None: """Test we abort if host name is invalid.""" patch_get_host.side_effect = gaierror result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONFIG_SCHEMA_TELNET, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG_SCHEMA_TELNET, ) assert result["type"] is FlowResultType.FORM @@ -243,10 +273,9 @@ async def test_abort_if_not_unique_id_setup(hass: HomeAssistant) -> None: ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONFIG_SCHEMA_TELNET, + DOMAIN, context={"source": SOURCE_USER} ) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_unique_id" @@ -263,9 +292,15 @@ async def test_update_uniqueid_exist( existing_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONFIG_SCHEMA_HTTP, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG_SCHEMA_HTTP, ) await hass.async_block_till_done() @@ -287,9 +322,15 @@ async def test_abort_invalid_unique_id(hass: HomeAssistant, connect_legacy) -> N connect_legacy.return_value.async_get_nvram.return_value = {} result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONFIG_SCHEMA_TELNET, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG_SCHEMA_TELNET, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "invalid_unique_id" diff --git a/tests/components/atag/test_config_flow.py b/tests/components/atag/test_config_flow.py index 59dd7fe8b4855c..b7d074ac5918d1 100644 --- a/tests/components/atag/test_config_flow.py +++ b/tests/components/atag/test_config_flow.py @@ -36,7 +36,15 @@ async def test_adding_second_device( await init_integration(hass, aioclient_mock, unique_id=UID) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=USER_INPUT + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, ) assert result["type"] is FlowResultType.ABORT @@ -46,7 +54,15 @@ async def test_adding_second_device( new_callable=PropertyMock(return_value="secondary_device"), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=USER_INPUT + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -57,9 +73,15 @@ async def test_connection_error( """Test we show user form on Atag connection error.""" mock_connection(aioclient_mock, conn_error=True) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=USER_INPUT, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, ) assert result["type"] is FlowResultType.FORM @@ -73,9 +95,15 @@ async def test_unauthorized( """Test we show correct form when Unauthorized error is raised.""" mock_connection(aioclient_mock, authorized=False) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=USER_INPUT, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -88,9 +116,15 @@ async def test_full_flow_implementation( """Test registering an integration and finishing flow works.""" mock_connection(aioclient_mock) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=USER_INPUT, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == UID diff --git a/tests/components/awair/test_config_flow.py b/tests/components/awair/test_config_flow.py index b27f20e83f3e2a..6f9d5da2354d99 100644 --- a/tests/components/awair/test_config_flow.py +++ b/tests/components/awair/test_config_flow.py @@ -38,7 +38,7 @@ async def test_invalid_access_token(hass: HomeAssistant) -> None: with patch("python_awair.AwairClient.query", side_effect=AuthError()): menu_step = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CLOUD_CONFIG + DOMAIN, context={"source": SOURCE_USER} ) form_step = await hass.config_entries.flow.async_configure( @@ -59,7 +59,7 @@ async def test_unexpected_api_error(hass: HomeAssistant) -> None: with patch("python_awair.AwairClient.query", side_effect=AwairError()): menu_step = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CLOUD_CONFIG + DOMAIN, context={"source": SOURCE_USER} ) form_step = await hass.config_entries.flow.async_configure( @@ -88,7 +88,7 @@ async def test_duplicate_error(hass: HomeAssistant, user, cloud_devices) -> None ).add_to_hass(hass) menu_step = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CLOUD_CONFIG + DOMAIN, context={"source": SOURCE_USER} ) form_step = await hass.config_entries.flow.async_configure( @@ -110,7 +110,7 @@ async def test_no_devices_error(hass: HomeAssistant, user, no_devices) -> None: with patch("python_awair.AwairClient.query", side_effect=[user, no_devices]): menu_step = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CLOUD_CONFIG + DOMAIN, context={"source": SOURCE_USER} ) form_step = await hass.config_entries.flow.async_configure( @@ -210,7 +210,7 @@ async def test_create_cloud_entry(hass: HomeAssistant, user, cloud_devices) -> N ), ): menu_step = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CLOUD_CONFIG + DOMAIN, context={"source": SOURCE_USER} ) form_step = await hass.config_entries.flow.async_configure( @@ -240,7 +240,7 @@ async def test_create_local_entry(hass: HomeAssistant, local_devices) -> None: ), ): menu_step = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=LOCAL_CONFIG + DOMAIN, context={"source": SOURCE_USER} ) form_step = await hass.config_entries.flow.async_configure( @@ -271,7 +271,7 @@ async def test_create_local_entry_from_discovery( """Test local API when device discovered after instructions shown.""" menu_step = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=LOCAL_CONFIG + DOMAIN, context={"source": SOURCE_USER} ) form_step = await hass.config_entries.flow.async_configure( @@ -319,7 +319,7 @@ async def test_create_local_entry_awair_error(hass: HomeAssistant) -> None: side_effect=AwairError(), ): menu_step = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=LOCAL_CONFIG + DOMAIN, context={"source": SOURCE_USER} ) form_step = await hass.config_entries.flow.async_configure( diff --git a/tests/components/azure_data_explorer/test_config_flow.py b/tests/components/azure_data_explorer/test_config_flow.py index 085e5ed5337091..267eacea568551 100644 --- a/tests/components/azure_data_explorer/test_config_flow.py +++ b/tests/components/azure_data_explorer/test_config_flow.py @@ -24,7 +24,15 @@ async def test_config_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=None, ) assert result["type"] is data_entry_flow.FlowResultType.FORM assert result["errors"] == {} @@ -57,9 +65,15 @@ async def test_config_flow_errors( ) -> None: """Test we handle connection KustoServiceError.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=None, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=None, ) assert result["type"] is data_entry_flow.FlowResultType.FORM assert result["errors"] == {} diff --git a/tests/components/azure_event_hub/test_config_flow.py b/tests/components/azure_event_hub/test_config_flow.py index 52685c36bbe682..a8492d566c39bc 100644 --- a/tests/components/azure_event_hub/test_config_flow.py +++ b/tests/components/azure_event_hub/test_config_flow.py @@ -55,7 +55,15 @@ async def test_form( ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=None, ) assert result["type"] is FlowResultType.FORM assert result["errors"] is None @@ -133,9 +141,15 @@ async def test_connection_error_sas( ) -> None: """Test we handle connection errors.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=BASE_CONFIG_SAS.copy(), + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=BASE_CONFIG_SAS.copy(), ) assert result["type"] is FlowResultType.FORM assert result["errors"] is None @@ -162,9 +176,15 @@ async def test_connection_error_cs( ) -> None: """Test we handle connection errors.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=BASE_CONFIG_CS.copy(), + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=BASE_CONFIG_CS.copy(), ) assert result["type"] is FlowResultType.FORM assert result["errors"] is None diff --git a/tests/components/blue_current/test_config_flow.py b/tests/components/blue_current/test_config_flow.py index a9dea70431ff70..c8ab0e419d4a9c 100644 --- a/tests/components/blue_current/test_config_flow.py +++ b/tests/components/blue_current/test_config_flow.py @@ -80,9 +80,15 @@ async def test_flow_fails(hass: HomeAssistant, error: Exception, message: str) - side_effect=error, ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={"api_token": "123"}, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"api_token": "123"}, ) assert result["errors"]["base"] == message assert result["type"] is FlowResultType.FORM diff --git a/tests/components/brunt/test_config_flow.py b/tests/components/brunt/test_config_flow.py index 7a805a9ee523c9..5a1bfd17efd942 100644 --- a/tests/components/brunt/test_config_flow.py +++ b/tests/components/brunt/test_config_flow.py @@ -22,7 +22,15 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=None, ) assert result["type"] is FlowResultType.FORM assert result["errors"] is None @@ -57,7 +65,15 @@ async def test_form_duplicate_login(hass: HomeAssistant) -> None: return_value=None, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -79,7 +95,15 @@ async def test_form_error(hass: HomeAssistant, side_effect, error_message) -> No side_effect=side_effect, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG, ) assert result["type"] is FlowResultType.FORM diff --git a/tests/components/canary/test_config_flow.py b/tests/components/canary/test_config_flow.py index 06aadc8297c272..6e531b26c8d25f 100644 --- a/tests/components/canary/test_config_flow.py +++ b/tests/components/canary/test_config_flow.py @@ -96,10 +96,9 @@ async def test_user_form_single_instance_allowed( await init_integration(hass, skip_entry_setup=True) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=USER_INPUT, + DOMAIN, context={"source": SOURCE_USER} ) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/color_extractor/test_config_flow.py b/tests/components/color_extractor/test_config_flow.py index 972b78b3f5962e..e1d8619056a608 100644 --- a/tests/components/color_extractor/test_config_flow.py +++ b/tests/components/color_extractor/test_config_flow.py @@ -42,7 +42,7 @@ async def test_single_instance_allowed(hass: HomeAssistant) -> None: mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={} + DOMAIN, context={"source": SOURCE_USER} ) assert result.get("type") is FlowResultType.ABORT diff --git a/tests/components/crownstone/test_config_flow.py b/tests/components/crownstone/test_config_flow.py index 1292731865ffba..2e155e71e4da13 100644 --- a/tests/components/crownstone/test_config_flow.py +++ b/tests/components/crownstone/test_config_flow.py @@ -22,6 +22,7 @@ MANUAL_PATH, ) from homeassistant.components.usb import USBDevice +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -143,8 +144,15 @@ async def start_config_flow(hass: HomeAssistant, mocked_cloud: MagicMock): "homeassistant.components.crownstone.config_flow.CrownstoneCloud", return_value=mocked_cloud, ): - return await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "user"}, data=mocked_login_input + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + return await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=mocked_login_input ) diff --git a/tests/components/daikin/test_config_flow.py b/tests/components/daikin/test_config_flow.py index 5afe55a7b9d26d..c1724e49faea4e 100644 --- a/tests/components/daikin/test_config_flow.py +++ b/tests/components/daikin/test_config_flow.py @@ -66,10 +66,9 @@ async def test_user(hass: HomeAssistant, mock_daikin) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: HOST}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: HOST}, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == HOST @@ -81,9 +80,15 @@ async def test_abort_if_already_setup(hass: HomeAssistant, mock_daikin) -> None: """Test we abort if Daikin is already setup.""" MockConfigEntry(domain="daikin", unique_id=MAC).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: HOST, KEY_MAC: MAC}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: HOST}, ) assert result["type"] is FlowResultType.ABORT @@ -106,9 +111,15 @@ async def test_device_abort(hass: HomeAssistant, mock_daikin, s_effect, reason) mock_daikin.side_effect = s_effect result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: HOST, KEY_MAC: MAC}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: HOST}, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": reason} @@ -118,9 +129,15 @@ async def test_device_abort(hass: HomeAssistant, mock_daikin, s_effect, reason) async def test_api_password_abort(hass: HomeAssistant) -> None: """Test device abort.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: HOST, CONF_API_KEY: "aa", CONF_PASSWORD: "aa"}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: HOST, CONF_API_KEY: "aa", CONF_PASSWORD: "aa"}, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "api_password"} @@ -159,9 +176,15 @@ async def test_discovery_zeroconf( MockConfigEntry(domain="daikin", unique_id=unique_id).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER, "unique_id": unique_id}, - data={CONF_HOST: HOST}, + DOMAIN, context={"source": SOURCE_USER, "unique_id": unique_id} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: HOST}, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/deluge/test_config_flow.py b/tests/components/deluge/test_config_flow.py index c336fc81cc638f..83a3d62688d499 100644 --- a/tests/components/deluge/test_config_flow.py +++ b/tests/components/deluge/test_config_flow.py @@ -58,9 +58,15 @@ def deluge_setup_fixture(): async def test_flow_user(hass: HomeAssistant, api) -> None: """Test user initialized flow.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONF_DATA, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == DEFAULT_NAME @@ -77,7 +83,15 @@ async def test_flow_user_already_configured(hass: HomeAssistant, api) -> None: entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, ) assert result["type"] is FlowResultType.ABORT @@ -87,7 +101,15 @@ async def test_flow_user_already_configured(hass: HomeAssistant, api) -> None: async def test_flow_user_cannot_connect(hass: HomeAssistant, conn_error) -> None: """Test user initialized flow with unreachable server.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -97,7 +119,15 @@ async def test_flow_user_cannot_connect(hass: HomeAssistant, conn_error) -> None async def test_flow_user_unknown_error(hass: HomeAssistant, unknown_error) -> None: """Test user initialized flow with unreachable server.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" diff --git a/tests/components/discord/test_config_flow.py b/tests/components/discord/test_config_flow.py index e9a1344c5553d1..04e49a74d82a1e 100644 --- a/tests/components/discord/test_config_flow.py +++ b/tests/components/discord/test_config_flow.py @@ -56,9 +56,15 @@ async def test_flow_user_invalid_auth(hass: HomeAssistant) -> None: with patch_discord_login() as mock: mock.side_effect = nextcord.LoginFailure result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=CONF_DATA, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -79,9 +85,15 @@ async def test_flow_user_cannot_connect(hass: HomeAssistant) -> None: with patch_discord_login() as mock: mock.side_effect = mock_exception() result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=CONF_DATA, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -102,9 +114,15 @@ async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: with patch_discord_login() as mock: mock.side_effect = Exception result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=CONF_DATA, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" diff --git a/tests/components/dlink/test_config_flow.py b/tests/components/dlink/test_config_flow.py index 6998299c76fabe..f3403aafc463a8 100644 --- a/tests/components/dlink/test_config_flow.py +++ b/tests/components/dlink/test_config_flow.py @@ -45,7 +45,15 @@ async def test_flow_user_already_configured( ) -> None: """Test user initialized flow with duplicate server.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, ) assert result["type"] is FlowResultType.ABORT @@ -60,7 +68,15 @@ async def test_flow_user_cannot_connect( """Test user initialized flow with unreachable server.""" with patch_config_flow(mocked_plug_legacy_no_auth): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -83,7 +99,15 @@ async def test_flow_user_unknown_error( with patch_config_flow(mocked_plug) as mock: mock.side_effect = Exception result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" diff --git a/tests/components/duco/conftest.py b/tests/components/duco/conftest.py index 655dd0dfc935e4..d92956d82b8912 100644 --- a/tests/components/duco/conftest.py +++ b/tests/components/duco/conftest.py @@ -1,6 +1,7 @@ """Fixtures for Duco tests.""" from collections.abc import Generator +from dataclasses import replace from typing import Any from unittest.mock import AsyncMock, patch @@ -10,6 +11,7 @@ ApiEndpointInfo, ApiInfo, BoardInfo, + BypassSupplyTemperatureTarget, ConfigNode, ConfigNodeOverview, ConfigValueString, @@ -190,6 +192,29 @@ def mock_ventilation_temperature_info() -> VentilationTemperatureInfo: ) +@pytest.fixture +def mock_bypass_supply_temperature_targets() -> dict[ + int, BypassSupplyTemperatureTarget +]: + """Return mock bypass supply temperature targets in Celsius.""" + return { + 1: BypassSupplyTemperatureTarget( + zone_id=1, + value=20.0, + minimum=15.0, + increment=0.1, + maximum=25.0, + ), + 2: BypassSupplyTemperatureTarget( + zone_id=2, + value=21.0, + minimum=15.0, + increment=0.1, + maximum=25.0, + ), + } + + @pytest.fixture def mock_nodes() -> list[Node]: """Return a list of nodes covering all supported types.""" @@ -244,6 +269,7 @@ def dynamic_sensor_nodes() -> dict[int, Node]: def mock_duco_client( mock_api_info: ApiInfo, mock_board_info: BoardInfo, + mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget], mock_lan_info: LanInfo, mock_nodes: list[Node], mock_node_actions: NodeListActionItemList, @@ -271,6 +297,20 @@ def mock_duco_client( client.async_get_ventilation_temperature_info.return_value = ( mock_ventilation_temperature_info ) + client.async_get_bypass_supply_temperature_targets.side_effect = ( + mock_bypass_supply_temperature_targets.copy + ) + client.async_set_bypass_supply_temperature_target.side_effect = ( + lambda zone_id, temperature: ( + mock_bypass_supply_temperature_targets.__setitem__( + zone_id, + replace( + mock_bypass_supply_temperature_targets[zone_id], + value=temperature, + ), + ) + ) + ) client.async_get_diagnostics.return_value = [ DiagComponent(component="Ventilation", status="Ok") ] diff --git a/tests/components/duco/snapshots/test_number.ambr b/tests/components/duco/snapshots/test_number.ambr new file mode 100644 index 00000000000000..cea66a3730240b --- /dev/null +++ b/tests/components/duco/snapshots/test_number.ambr @@ -0,0 +1,123 @@ +# serializer version: 1 +# name: test_bypass_supply_temperature_target_number_entities_state[number.living_bypass_target_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 25.0, + : 15.0, + : , + : 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.living_bypass_target_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Bypass target 1', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Bypass target 1', + 'platform': 'duco', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass_supply_target_temperature_zone', + 'unique_id': 'aa:bb:cc:dd:ee:ff_1_bypass_supply_target_temperature_zone_1', + 'unit_of_measurement': , + }) +# --- +# name: test_bypass_supply_temperature_target_number_entities_state[number.living_bypass_target_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Bypass target 1', + : 25.0, + : 15.0, + : , + : 0.1, + : , + }), + 'context': , + 'entity_id': 'number.living_bypass_target_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_bypass_supply_temperature_target_number_entities_state[number.living_bypass_target_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 25.0, + : 15.0, + : , + : 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.living_bypass_target_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Bypass target 2', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Bypass target 2', + 'platform': 'duco', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass_supply_target_temperature_zone', + 'unique_id': 'aa:bb:cc:dd:ee:ff_1_bypass_supply_target_temperature_zone_2', + 'unit_of_measurement': , + }) +# --- +# name: test_bypass_supply_temperature_target_number_entities_state[number.living_bypass_target_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Bypass target 2', + : 25.0, + : 15.0, + : , + : 0.1, + : , + }), + 'context': , + 'entity_id': 'number.living_bypass_target_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.0', + }) +# --- diff --git a/tests/components/duco/test_init.py b/tests/components/duco/test_init.py index 9d13f3abf7e139..70351083869546 100644 --- a/tests/components/duco/test_init.py +++ b/tests/components/duco/test_init.py @@ -5,6 +5,7 @@ from duco_connectivity import ( BoardInfo, + BypassSupplyTemperatureTarget, ConfigNode, ConfigNodeOverview, ConfigValueString, @@ -12,6 +13,7 @@ DucoConnectionError, DucoError, DucoResponseError, + DucoUnsupportedCapabilityError, LanInfo, Node, NodeListActionItemList, @@ -231,6 +233,104 @@ async def test_setup_entry_recovers_from_optional_temperature_capability_failure assert state.state == "5.5" +@pytest.mark.parametrize( + ("exception", "translation_key"), + [ + pytest.param( + DucoConnectionError("Connection refused"), + "cannot_connect", + id="connection_error", + ), + pytest.param(DucoError("API error"), "api_error", id="duco_error"), + pytest.param( + DucoResponseError(500, "/config"), + "api_error", + id="response_error", + ), + ], +) +async def test_setup_entry_retries_on_bypass_temperature_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + exception: Exception, + translation_key: str, +) -> None: + """Test setup retries when fetching bypass temperature targets fails.""" + mock_duco_client.async_get_bypass_supply_temperature_targets.side_effect = exception + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry.error_reason_translation_key == translation_key + assert mock_config_entry.error_reason_translation_placeholders is None + + +async def test_unsupported_bypass_temperature_capability_is_not_repolled( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, +) -> None: + """Test an unsupported bulk bypass target endpoint is not polled again.""" + mock_duco_client.async_get_bypass_supply_temperature_targets.side_effect = ( + DucoUnsupportedCapabilityError( + 400, + "/config", + '{"Code":3,"Result":"FAILED"}', + ) + ) + mock_config_entry.add_to_hass(hass) + + 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 hass.states.get("number.living_bypass_target_1") is None + assert hass.states.get("number.living_bypass_target_2") is None + mock_duco_client.async_get_bypass_supply_temperature_targets.assert_awaited_once_with() + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + mock_duco_client.async_get_bypass_supply_temperature_targets.assert_awaited_once_with() + + +async def test_missing_bypass_temperature_targets_are_retried( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget], + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, +) -> None: + """Test missing bypass targets are retried and can later create entities.""" + targets_without_zone_1 = { + k: v for k, v in mock_bypass_supply_temperature_targets.items() if k != 1 + } + mock_duco_client.async_get_bypass_supply_temperature_targets.side_effect = [ + targets_without_zone_1, + mock_bypass_supply_temperature_targets.copy(), + ] + mock_config_entry.add_to_hass(hass) + + 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 hass.states.get("number.living_bypass_target_1") is None + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("number.living_bypass_target_1") + assert state is not None + assert state.state == "20.0" + + async def test_setup_entry_ignores_node_name_config_failures( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -356,6 +456,16 @@ async def test_setup_entry_creates_http_client( ( mock_client_class.return_value.async_get_ventilation_temperature_info.return_value ) = VentilationTemperatureInfo() + + mock_client_class.return_value.async_get_bypass_supply_temperature_targets.return_value = { + 1: BypassSupplyTemperatureTarget( + zone_id=1, + value=20.0, + minimum=15.0, + increment=0.1, + maximum=25.0, + ) + } mock_client_class.return_value.async_get_diagnostics.return_value = [ DiagComponent(component="Ventilation", status="Ok") ] diff --git a/tests/components/duco/test_number.py b/tests/components/duco/test_number.py new file mode 100644 index 00000000000000..5f910402520deb --- /dev/null +++ b/tests/components/duco/test_number.py @@ -0,0 +1,337 @@ +"""Tests for the Duco number platform.""" + +from dataclasses import replace +from unittest.mock import AsyncMock + +from duco_connectivity import ( + BypassSupplyTemperatureTarget, + DucoError, + DucoRateLimitError, +) +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.duco.const import SCAN_INTERVAL +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM + +from . import setup_platform_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +_ZONE_1_ENTITY_ID = "number.living_bypass_target_1" +_ZONE_2_ENTITY_ID = "number.living_bypass_target_2" +_ZONE_8_ENTITY_ID = "number.living_bypass_target_8" + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, +) -> MockConfigEntry: + """Set up only the number platform for testing.""" + return await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER]) + + +async def test_bypass_supply_temperature_target_numbers_support_all_exposed_zones( + hass: HomeAssistant, + mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget], + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, +) -> None: + """Test bypass target controls are created for all exposed zones.""" + mock_bypass_supply_temperature_targets[8] = BypassSupplyTemperatureTarget( + zone_id=8, + value=22.0, + minimum=15.0, + increment=0.1, + maximum=25.0, + ) + + await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER]) + + for entity_id in ( + _ZONE_1_ENTITY_ID, + _ZONE_2_ENTITY_ID, + _ZONE_8_ENTITY_ID, + ): + assert hass.states.get(entity_id) is not None + + mock_duco_client.async_get_bypass_supply_temperature_targets.assert_awaited_once_with() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_bypass_supply_temperature_target_number_entities_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test bypass supply temperature target number entity states.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("mock_duco_client") +async def test_bypass_supply_temperature_targets_missing_skips_number_creation( + hass: HomeAssistant, + mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget], + mock_config_entry: MockConfigEntry, +) -> None: + """Test no number entities are created when bypass targets are unavailable.""" + mock_bypass_supply_temperature_targets.clear() + + await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER]) + + assert hass.states.get(_ZONE_1_ENTITY_ID) is None + assert hass.states.get(_ZONE_2_ENTITY_ID) is None + + +@pytest.mark.parametrize( + "field", + [ + pytest.param("minimum", id="missing_minimum"), + pytest.param("maximum", id="missing_maximum"), + pytest.param("increment", id="missing_increment"), + ], +) +@pytest.mark.usefixtures("mock_duco_client") +async def test_bypass_supply_temperature_target_incomplete_metadata_skips_number_creation( + hass: HomeAssistant, + mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget], + mock_config_entry: MockConfigEntry, + field: str, +) -> None: + """Test incomplete target metadata does not expose an invalid control.""" + mock_bypass_supply_temperature_targets[1] = replace( + mock_bypass_supply_temperature_targets[1], **{field: None} + ) + + await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER]) + + assert hass.states.get(_ZONE_1_ENTITY_ID) is None + assert hass.states.get(_ZONE_2_ENTITY_ID) is not None + + +@pytest.mark.usefixtures("init_integration") +async def test_set_bypass_supply_temperature_target( + hass: HomeAssistant, + mock_duco_client: AsyncMock, +) -> None: + """Test setting a bypass target refreshes the number from the box.""" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 20.5}, + blocking=True, + ) + + mock_duco_client.async_set_bypass_supply_temperature_target.assert_awaited_once_with( + 1, 20.5 + ) + state = hass.states.get(_ZONE_1_ENTITY_ID) + assert state is not None + assert state.state == "20.5" + + +async def test_set_bypass_supply_temperature_target_honors_increment_metadata( + hass: HomeAssistant, + mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget], + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, +) -> None: + """Test bypass target writes follow the API-provided increment metadata.""" + mock_bypass_supply_temperature_targets[1] = replace( + mock_bypass_supply_temperature_targets[1], + minimum=10.0, + increment=0.5, + maximum=25.5, + ) + + await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER]) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 20.5}, + blocking=True, + ) + + mock_duco_client.async_set_bypass_supply_temperature_target.assert_awaited_once_with( + 1, 20.5 + ) + + with pytest.raises( + HomeAssistantError, + match="supported increment of 0.5 starting at 10.0", + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 20.2}, + blocking=True, + ) + + +async def test_set_bypass_supply_temperature_target_in_fahrenheit_units( + hass: HomeAssistant, + mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget], + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, +) -> None: + """Test Fahrenheit service writes normalize to the nearest supported Celsius step.""" + hass.config.units = US_CUSTOMARY_SYSTEM + mock_bypass_supply_temperature_targets[1] = replace( + mock_bypass_supply_temperature_targets[1], + minimum=10.0, + increment=0.5, + maximum=25.5, + ) + + await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER]) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 69.0}, + blocking=True, + ) + + mock_duco_client.async_set_bypass_supply_temperature_target.assert_awaited_once_with( + 1, 20.5 + ) + state = hass.states.get(_ZONE_1_ENTITY_ID) + assert state is not None + assert state.state == "68.9" + + +async def test_set_bypass_supply_temperature_target_stays_within_maximum( + hass: HomeAssistant, + mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget], + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, +) -> None: + """Test normalization never rounds past a maximum that is not a whole step.""" + hass.config.units = US_CUSTOMARY_SYSTEM + mock_bypass_supply_temperature_targets[1] = replace( + mock_bypass_supply_temperature_targets[1], + minimum=10.0, + increment=0.5, + maximum=24.8, + ) + + await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER]) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 76.6}, + blocking=True, + ) + + mock_duco_client.async_set_bypass_supply_temperature_target.assert_awaited_once_with( + 1, 24.5 + ) + + +@pytest.mark.usefixtures("mock_duco_client") +async def test_bypass_supply_temperature_target_becomes_unavailable_when_missing( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget], + mock_config_entry: MockConfigEntry, +) -> None: + """Test a bypass target becomes unavailable when a bulk read omits it.""" + await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER]) + + state = hass.states.get(_ZONE_1_ENTITY_ID) + assert state is not None + assert state.state == "20.0" + + updated_target = replace(mock_bypass_supply_temperature_targets.pop(1), value=20.5) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(_ZONE_1_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + mock_bypass_supply_temperature_targets[1] = updated_target + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(_ZONE_1_ENTITY_ID) + assert state is not None + assert state.state == "20.5" + + +async def test_bypass_supply_temperature_target_recovers_from_refresh_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget], + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, +) -> None: + """Test a bypass target recovers after a transient refresh error.""" + await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER]) + + state = hass.states.get(_ZONE_1_ENTITY_ID) + assert state is not None + assert state.state == "20.0" + + mock_duco_client.async_get_bypass_supply_temperature_targets.side_effect = [ + DucoError("Temporary bypass target failure"), + mock_bypass_supply_temperature_targets.copy(), + ] + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(_ZONE_1_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(_ZONE_1_ENTITY_ID) + assert state is not None + assert state.state == "20.0" + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("exception", "match"), + [ + pytest.param( + DucoError("Unexpected error"), + "Failed to set bypass supply target temperature", + id="duco_error", + ), + pytest.param(DucoRateLimitError(), "daily write limit", id="rate_limit"), + ], +) +async def test_set_bypass_supply_temperature_target_error( + hass: HomeAssistant, + mock_duco_client: AsyncMock, + exception: Exception, + match: str, +) -> None: + """Test write failures raise translated Home Assistant errors.""" + mock_duco_client.async_set_bypass_supply_temperature_target.side_effect = exception + + with pytest.raises(HomeAssistantError, match=match): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 20.5}, + blocking=True, + ) diff --git a/tests/components/dunehd/test_config_flow.py b/tests/components/dunehd/test_config_flow.py index a35c1eec4ccac0..246eb15b1f6046 100644 --- a/tests/components/dunehd/test_config_flow.py +++ b/tests/components/dunehd/test_config_flow.py @@ -19,7 +19,15 @@ async def test_user_invalid_host(hass: HomeAssistant) -> None: """Test that errors are shown when the host is invalid.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "invalid/host"} + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "invalid/host"}, ) assert result["errors"] == {CONF_HOST: "invalid_host"} @@ -34,7 +42,15 @@ async def test_user_very_long_host(hass: HomeAssistant) -> None: "host_very_long_host_very_long_host" ) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: long_host} + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: long_host}, ) assert result["errors"] == {CONF_HOST: "invalid_host"} @@ -44,7 +60,15 @@ async def test_user_cannot_connect(hass: HomeAssistant) -> None: """Test that errors are shown when cannot connect to the host.""" with patch("pdunehd.DuneHDPlayer.update_state", return_value={}): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG_IP + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG_IP, ) assert result["errors"] == {CONF_HOST: "cannot_connect"} @@ -61,7 +85,15 @@ async def test_duplicate_error(hass: HomeAssistant) -> None: with patch("pdunehd.DuneHDPlayer.update_state", return_value=DUNEHD_STATE): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG_HOSTNAME + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG_HOSTNAME, ) assert result["errors"] == {CONF_HOST: "already_configured"} @@ -74,7 +106,15 @@ async def test_create_entry(hass: HomeAssistant) -> None: patch("pdunehd.DuneHDPlayer.update_state", return_value=DUNEHD_STATE), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG_HOSTNAME + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG_HOSTNAME, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -89,9 +129,15 @@ async def test_create_entry_with_ipv6_address(hass: HomeAssistant) -> None: patch("pdunehd.DuneHDPlayer.update_state", return_value=DUNEHD_STATE), ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: "2001:db8::1428:57ab"}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "2001:db8::1428:57ab"}, ) assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/dynalite/test_config_flow.py b/tests/components/dynalite/test_config_flow.py index 20ee42d33b527a..c3876b35cf46a2 100644 --- a/tests/components/dynalite/test_config_flow.py +++ b/tests/components/dynalite/test_config_flow.py @@ -37,9 +37,15 @@ async def test_flow( side_effect=[first_con, second_con], ): result = await hass.config_entries.flow.async_init( - dynalite.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_HOST: host}, + dynalite.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: host}, ) await hass.async_block_till_done() assert result["type"] == exp_type @@ -58,9 +64,15 @@ async def test_existing(hass: HomeAssistant) -> None: return_value=True, ): result = await hass.config_entries.flow.async_init( - dynalite.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_HOST: host}, + dynalite.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: host}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -85,9 +97,15 @@ async def test_existing_abort_update(hass: HomeAssistant) -> None: mock_dyn_dev().configure.assert_called_once() assert mock_dyn_dev().configure.mock_calls[0][1][0]["port"] == port1 result = await hass.config_entries.flow.async_init( - dynalite.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_HOST: host, CONF_PORT: port2}, + dynalite.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: host, CONF_PORT: port2}, ) await hass.async_block_till_done() assert mock_dyn_dev().configure.call_count == 1 @@ -106,9 +124,15 @@ async def test_two_entries(hass: HomeAssistant) -> None: return_value=True, ): result = await hass.config_entries.flow.async_init( - dynalite.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_HOST: host2}, + dynalite.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: host2}, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].state is ConfigEntryState.LOADED diff --git a/tests/components/edl21/test_config_flow.py b/tests/components/edl21/test_config_flow.py index 97ad1464d77f6f..05db9a969b34a2 100644 --- a/tests/components/edl21/test_config_flow.py +++ b/tests/components/edl21/test_config_flow.py @@ -44,9 +44,15 @@ async def test_integration_already_exists(hass: HomeAssistant) -> None: ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=VALID_CONFIG, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/egauge/test_config_flow.py b/tests/components/egauge/test_config_flow.py index 7c2b094afeab67..c9e6b21e7a3fc9 100644 --- a/tests/components/egauge/test_config_flow.py +++ b/tests/components/egauge/test_config_flow.py @@ -73,9 +73,15 @@ async def test_user_flow_errors( mock_egauge_client.get_device_serial_number.side_effect = side_effect result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_HOST: "192.168.1.100", CONF_USERNAME: "admin", CONF_PASSWORD: "wrong", @@ -119,9 +125,15 @@ async def test_user_flow_already_configured( mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_HOST: "http://192.168.1.200", CONF_USERNAME: "admin", CONF_PASSWORD: "secret", diff --git a/tests/components/electrasmart/test_config_flow.py b/tests/components/electrasmart/test_config_flow.py index 500377fb702d7d..78ca7adfef0263 100644 --- a/tests/components/electrasmart/test_config_flow.py +++ b/tests/components/electrasmart/test_config_flow.py @@ -28,18 +28,30 @@ async def test_form(hass: HomeAssistant) -> None: ): # test with required result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=None, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=None, ) assert result["step_id"] == "user" # test with required result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_PHONE_NUMBER: "0521234567"}, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PHONE_NUMBER: "0521234567"}, ) assert result["type"] is FlowResultType.FORM @@ -70,9 +82,15 @@ async def test_one_time_password(hass: HomeAssistant) -> None: ), ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_PHONE_NUMBER: "0521234567", CONF_OTP: "1234"}, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PHONE_NUMBER: "0521234567"}, ) # test with required @@ -98,9 +116,15 @@ async def test_one_time_password_api_error(hass: HomeAssistant) -> None: ), ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_PHONE_NUMBER: "0521234567"}, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PHONE_NUMBER: "0521234567"}, ) result = await hass.config_entries.flow.async_configure( @@ -119,9 +143,15 @@ async def test_cannot_connect(hass: HomeAssistant) -> None: ): # test with required result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_PHONE_NUMBER: "0521234567"}, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PHONE_NUMBER: "0521234567"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -141,9 +171,15 @@ async def test_invalid_phone_number(hass: HomeAssistant) -> None: ): # test with required result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_PHONE_NUMBER: "0521234567"}, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PHONE_NUMBER: "0521234567"}, ) assert result["type"] is FlowResultType.FORM @@ -173,9 +209,15 @@ async def test_invalid_auth(hass: HomeAssistant) -> None: ): # test with required result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_PHONE_NUMBER: "0521234567", CONF_OTP: "1234"}, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PHONE_NUMBER: "0521234567"}, ) result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/elgato/snapshots/test_number.ambr b/tests/components/elgato/snapshots/test_number.ambr new file mode 100644 index 00000000000000..47b4ef7b5a840f --- /dev/null +++ b/tests/components/elgato/snapshots/test_number.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_numbers[number.frenck_power_on_brightness-50-expected0-key-light] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Frenck Power-on brightness', + : 100, + : 0, + : , + : 1, + : '%', + }), + 'context': , + 'entity_id': 'number.frenck_power_on_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_numbers[number.frenck_power_on_brightness-50-expected0-key-light].1 + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.frenck_power_on_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power-on brightness', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power-on brightness', + 'platform': 'elgato', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_on_brightness', + 'unique_id': 'CN11A1A00001_power_on_brightness', + 'unit_of_measurement': '%', + }) +# --- +# name: test_numbers[number.frenck_power_on_brightness-50-expected0-key-light].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '53', + 'id': , + 'identifiers': set({ + tuple( + 'elgato', + 'CN11A1A00001', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Elgato', + 'model': 'Elgato Key Light', + 'model_id': None, + 'name': 'Frenck', + 'name_by_user': None, + 'serial_number': 'CN11A1A00001', + 'sw_version': '1.0.3 (192)', + 'via_device_id': None, + }) +# --- +# name: test_numbers[number.frenck_power_on_color_temperature-5000-expected1-key-light] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Frenck Power-on color temperature', + : 6993, + : 2900, + : , + : 50, + : , + }), + 'context': , + 'entity_id': 'number.frenck_power_on_color_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4694', + }) +# --- +# name: test_numbers[number.frenck_power_on_color_temperature-5000-expected1-key-light].1 + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 6993, + : 2900, + : , + : 50, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.frenck_power_on_color_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power-on color temperature', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power-on color temperature', + 'platform': 'elgato', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_on_temperature', + 'unique_id': 'CN11A1A00001_power_on_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.frenck_power_on_color_temperature-5000-expected1-key-light].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '53', + 'id': , + 'identifiers': set({ + tuple( + 'elgato', + 'CN11A1A00001', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Elgato', + 'model': 'Elgato Key Light', + 'model_id': None, + 'name': 'Frenck', + 'name_by_user': None, + 'serial_number': 'CN11A1A00001', + 'sw_version': '1.0.3 (192)', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/elgato/snapshots/test_select.ambr b/tests/components/elgato/snapshots/test_select.ambr new file mode 100644 index 00000000000000..923b8ba141f122 --- /dev/null +++ b/tests/components/elgato/snapshots/test_select.ambr @@ -0,0 +1,94 @@ +# serializer version: 1 +# name: test_power_on_behavior[key-light] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Frenck Power-on behavior', + : list([ + 'restore_last', + 'use_defaults', + ]), + }), + 'context': , + 'entity_id': 'select.frenck_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'restore_last', + }) +# --- +# name: test_power_on_behavior[key-light].1 + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'restore_last', + 'use_defaults', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.frenck_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power-on behavior', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power-on behavior', + 'platform': 'elgato', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_on_behavior', + 'unique_id': 'CN11A1A00001_power_on_behavior', + 'unit_of_measurement': None, + }) +# --- +# name: test_power_on_behavior[key-light].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '53', + 'id': , + 'identifiers': set({ + tuple( + 'elgato', + 'CN11A1A00001', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Elgato', + 'model': 'Elgato Key Light', + 'model_id': None, + 'name': 'Frenck', + 'name_by_user': None, + 'serial_number': 'CN11A1A00001', + 'sw_version': '1.0.3 (192)', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/elgato/snapshots/test_sensor.ambr b/tests/components/elgato/snapshots/test_sensor.ambr index 4e0ae5d822ee5e..053ae449a705b9 100644 --- a/tests/components/elgato/snapshots/test_sensor.ambr +++ b/tests/components/elgato/snapshots/test_sensor.ambr @@ -468,3 +468,180 @@ 'via_device_id': None, }) # --- +# name: test_sensors[sensor.frenck_wi_fi_rssi-key-light-mini] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'signal_strength', + : 'Frenck Wi-Fi RSSI', + : , + : 'dBm', + }), + 'context': , + 'entity_id': 'sensor.frenck_wi_fi_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-41', + }) +# --- +# name: test_sensors[sensor.frenck_wi_fi_rssi-key-light-mini].1 + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.frenck_wi_fi_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi RSSI', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wi-Fi RSSI', + 'platform': 'elgato', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_rssi', + 'unique_id': 'GW24L1A02987_wifi_rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensors[sensor.frenck_wi_fi_rssi-key-light-mini].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '202', + 'id': , + 'identifiers': set({ + tuple( + 'elgato', + 'GW24L1A02987', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Elgato', + 'model': 'Elgato Key Light Mini', + 'model_id': None, + 'name': 'Frenck', + 'name_by_user': None, + 'serial_number': 'GW24L1A02987', + 'sw_version': '1.0.4 (229)', + 'via_device_id': None, + }) +# --- +# name: test_sensors[sensor.frenck_wi_fi_signal_strength-key-light-mini] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Frenck Wi-Fi signal strength', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.frenck_wi_fi_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[sensor.frenck_wi_fi_signal_strength-key-light-mini].1 + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.frenck_wi_fi_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi signal strength', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi signal strength', + 'platform': 'elgato', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_signal_strength', + 'unique_id': 'GW24L1A02987_wifi_signal_strength', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.frenck_wi_fi_signal_strength-key-light-mini].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '202', + 'id': , + 'identifiers': set({ + tuple( + 'elgato', + 'GW24L1A02987', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Elgato', + 'model': 'Elgato Key Light Mini', + 'model_id': None, + 'name': 'Frenck', + 'name_by_user': None, + 'serial_number': 'GW24L1A02987', + 'sw_version': '1.0.4 (229)', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/elgato/test_number.py b/tests/components/elgato/test_number.py new file mode 100644 index 00000000000000..d6b787fa712b8b --- /dev/null +++ b/tests/components/elgato/test_number.py @@ -0,0 +1,159 @@ +"""Tests for the Elgato number platform.""" + +from unittest.mock import MagicMock + +from elgato import ElgatoError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + +# Each test says which device it wants, and when the integration is set up. + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize("device_fixtures", ["key-light"]) +@pytest.mark.parametrize( + ("entity_id", "value", "expected"), + [ + ("number.frenck_power_on_brightness", 50, {"brightness": 50}), + ("number.frenck_power_on_color_temperature", 5000, {"temperature": 200}), + ], +) +async def test_numbers( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_elgato: MagicMock, + snapshot: SnapshotAssertion, + entity_id: str, + value: float, + expected: dict[str, int], +) -> None: + """Test the Elgato numbers.""" + assert (state := hass.states.get(entity_id)) + assert state == snapshot + + assert (entry := entity_registry.async_get(entity_id)) + assert entry == snapshot + + assert entry.device_id + assert (device_entry := device_registry.async_get(entry.device_id)) + assert device_entry == snapshot + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + + assert len(mock_elgato.power_on_behavior.mock_calls) == 1 + mock_elgato.power_on_behavior.assert_called_once_with(**expected) + + mock_elgato.power_on_behavior.side_effect = ElgatoError + + with pytest.raises( + HomeAssistantError, + match="An unknown error occurred while communicating with the Elgato device", + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + + assert len(mock_elgato.power_on_behavior.mock_calls) == 2 + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize("device_fixtures", ["light-strip"]) +async def test_power_on_temperature_unknown(hass: HomeAssistant) -> None: + """Test a light that powers on to a color instead of a temperature. + + It reports a power-on temperature of zero, which is not a temperature. + The entity still exists, because whether the device reports the field is + a property of the device, while what it currently holds is not. + """ + assert hass.states.get("number.frenck_power_on_brightness") + + assert (state := hass.states.get("number.frenck_power_on_color_temperature")) + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("device_fixtures", "expected_range"), + [ + ("key-light", (2900, 6993)), + ("light-strip", (3500, 6500)), + ], +) +async def test_power_on_temperature_range( + hass: HomeAssistant, + expected_range: tuple[int, int], +) -> None: + """Test the number stays inside what the device can actually do. + + A light that does color reaches less far at either end, and the number + has to agree with the light entity about that. + """ + minimum, maximum = expected_range + + assert (state := hass.states.get("number.frenck_power_on_color_temperature")) + assert state.attributes["min"] == minimum + assert state.attributes["max"] == maximum + + +@pytest.mark.parametrize("device_fixtures", ["light-strip"]) +async def test_power_on_temperature_at_the_edge( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_elgato: MagicMock, +) -> None: + """Test the reported value stays inside the range that can be set. + + Setting the maximum of 6500 K stores 153 mireds, which converts back to + 6535 K. Reporting that would put the entity above a maximum the user + cannot submit again. + """ + mock_elgato.settings.return_value.power_on_temperature = 153 + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get("number.frenck_power_on_color_temperature")) + assert state.state == "6500" + + +@pytest.mark.parametrize("device_fixtures", ["light-strip"]) +async def test_power_on_temperature_absent( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_elgato: MagicMock, +) -> None: + """Test a device that does not report a power-on temperature at all. + + Reporting the field is what the entity hangs off, so a device without it + gets no entity, while the brightness one is unaffected. + """ + mock_elgato.settings.return_value.power_on_temperature = None + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("number.frenck_power_on_brightness") + assert not hass.states.get("number.frenck_power_on_color_temperature") diff --git a/tests/components/elgato/test_select.py b/tests/components/elgato/test_select.py new file mode 100644 index 00000000000000..2b317595f652f9 --- /dev/null +++ b/tests/components/elgato/test_select.py @@ -0,0 +1,70 @@ +"""Tests for the Elgato select platform.""" + +from unittest.mock import MagicMock + +from elgato import ElgatoError, PowerOnBehavior +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = [ + pytest.mark.parametrize("device_fixtures", ["key-light"]), + pytest.mark.usefixtures("device_fixtures", "init_integration"), +] + + +async def test_power_on_behavior( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_elgato: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the Elgato power-on behavior select.""" + entity_id = "select.frenck_power_on_behavior" + + assert (state := hass.states.get(entity_id)) + assert state == snapshot + + assert (entry := entity_registry.async_get(entity_id)) + assert entry == snapshot + + assert entry.device_id + assert (device_entry := device_registry.async_get(entry.device_id)) + assert device_entry == snapshot + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "use_defaults"}, + blocking=True, + ) + + assert len(mock_elgato.power_on_behavior.mock_calls) == 1 + mock_elgato.power_on_behavior.assert_called_once_with( + behavior=PowerOnBehavior.USE_DEFAULTS + ) + + mock_elgato.power_on_behavior.side_effect = ElgatoError + + with pytest.raises( + HomeAssistantError, + match="An unknown error occurred while communicating with the Elgato device", + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "restore_last"}, + blocking=True, + ) + + assert len(mock_elgato.power_on_behavior.mock_calls) == 2 diff --git a/tests/components/elgato/test_sensor.py b/tests/components/elgato/test_sensor.py index 1ad37d938e789c..f84dc8da5ef3a0 100644 --- a/tests/components/elgato/test_sensor.py +++ b/tests/components/elgato/test_sensor.py @@ -21,6 +21,8 @@ "sensor.frenck_charging_current", "sensor.frenck_charging_power", "sensor.frenck_charging_voltage", + "sensor.frenck_wi_fi_rssi", + "sensor.frenck_wi_fi_signal_strength", ], ) async def test_sensors( @@ -50,6 +52,7 @@ async def test_sensors( "sensor.frenck_charging_current", "sensor.frenck_charging_power", "sensor.frenck_charging_voltage", + "sensor.frenck_wi_fi_rssi", ], ) async def test_disabled_by_default_sensors( @@ -61,3 +64,19 @@ async def test_disabled_by_default_sensors( assert (entry := entity_registry.async_get(entity_id)) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +async def test_wifi_signal_strength_is_enabled( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test the signal strength percentage is on without asking for it. + + A percentage is what someone can actually read, so it is the one that + shows up by default. The dBm behind it stays available but off. + """ + assert (state := hass.states.get("sensor.frenck_wi_fi_signal_strength")) + assert state.state == "100" + + assert (entry := entity_registry.async_get("sensor.frenck_wi_fi_signal_strength")) + assert not entry.disabled diff --git a/tests/components/emulated_roku/test_config_flow.py b/tests/components/emulated_roku/test_config_flow.py index a999ec20503d49..c282edd2b6d9ee 100644 --- a/tests/components/emulated_roku/test_config_flow.py +++ b/tests/components/emulated_roku/test_config_flow.py @@ -25,9 +25,15 @@ def mock_setup_entry() -> Generator[AsyncMock]: async def test_flow_works(hass: HomeAssistant) -> None: """Test that config flow works.""" result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={"name": "Emulated Roku Test", "listen_port": 8060}, + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"name": "Emulated Roku Test", "listen_port": 8060}, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -42,9 +48,15 @@ async def test_flow_already_registered_entry(hass: HomeAssistant) -> None: ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={"name": "Emulated Roku Test", "listen_port": 8062}, + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"name": "Emulated Roku Test", "listen_port": 8062}, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/energenie_power_sockets/test_config_flow.py b/tests/components/energenie_power_sockets/test_config_flow.py index b7e774f99c3f00..1fddb60f987a6c 100644 --- a/tests/components/energenie_power_sockets/test_config_flow.py +++ b/tests/components/energenie_power_sockets/test_config_flow.py @@ -68,9 +68,7 @@ async def test_user_flow_no_new_device( valid_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=None, + DOMAIN, context={"source": SOURCE_USER} ) await hass.async_block_till_done() diff --git a/tests/components/environment_canada/test_config_flow.py b/tests/components/environment_canada/test_config_flow.py index 3c7643a9db0b7c..0aa132220cc16b 100644 --- a/tests/components/environment_canada/test_config_flow.py +++ b/tests/components/environment_canada/test_config_flow.py @@ -180,7 +180,15 @@ async def test_lat_lon_not_specified(hass: HomeAssistant) -> None: del fake_config[CONF_LATITUDE] del fake_config[CONF_LONGITUDE] result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=fake_config + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=fake_config, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/faa_delays/test_config_flow.py b/tests/components/faa_delays/test_config_flow.py index 4420bc7363204d..393f8a041ff054 100644 --- a/tests/components/faa_delays/test_config_flow.py +++ b/tests/components/faa_delays/test_config_flow.py @@ -59,7 +59,15 @@ async def test_duplicate_error(hass: HomeAssistant) -> None: MockConfigEntry(domain=DOMAIN, unique_id="test", data=conf).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=conf, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/fireservicerota/test_config_flow.py b/tests/components/fireservicerota/test_config_flow.py index 8d150034ec9b13..ba09a598745e53 100644 --- a/tests/components/fireservicerota/test_config_flow.py +++ b/tests/components/fireservicerota/test_config_flow.py @@ -56,7 +56,15 @@ async def test_abort_if_already_setup(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=MOCK_CONF + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONF, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -70,7 +78,15 @@ async def test_invalid_credentials(hass: HomeAssistant) -> None: side_effect=InvalidAuthError, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=MOCK_CONF + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONF, ) assert result["errors"] == {"base": "invalid_auth"} @@ -91,7 +107,15 @@ async def test_step_user(hass: HomeAssistant) -> None: mock_fireservicerota.request_tokens.return_value = MOCK_TOKEN_INFO result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=MOCK_CONF + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONF, ) await hass.async_block_till_done() diff --git a/tests/components/freedompro/test_config_flow.py b/tests/components/freedompro/test_config_flow.py index 0999f15766192a..d482dab78a13e9 100644 --- a/tests/components/freedompro/test_config_flow.py +++ b/tests/components/freedompro/test_config_flow.py @@ -39,9 +39,15 @@ async def test_invalid_auth(hass: HomeAssistant) -> None: }, ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=VALID_CONFIG, ) assert result["errors"] == {"base": "invalid_auth"} @@ -57,9 +63,15 @@ async def test_connection_error(hass: HomeAssistant) -> None: }, ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=VALID_CONFIG, ) assert result["errors"] == {"base": "cannot_connect"} @@ -75,9 +87,15 @@ async def test_create_entry(hass: HomeAssistant) -> None: }, ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=VALID_CONFIG, ) assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/frontier_silicon/conftest.py b/tests/components/frontier_silicon/conftest.py index 63701ca2eb39fd..fb27b5d58a1cef 100644 --- a/tests/components/frontier_silicon/conftest.py +++ b/tests/components/frontier_silicon/conftest.py @@ -56,6 +56,8 @@ def mock_afsapi() -> Generator[AsyncMock]: client.get_volume.return_value = 3 client.get_volume_steps.return_value = 2 client.get_play_caps.return_value = PlayCaps(0) + client.get_dst.return_value = True + client.set_dst.return_value = True modes = [ PlayerMode( diff --git a/tests/components/frontier_silicon/test_media_player.py b/tests/components/frontier_silicon/test_media_player.py index 9e7637a6865da0..6c23c86ae5bfe6 100644 --- a/tests/components/frontier_silicon/test_media_player.py +++ b/tests/components/frontier_silicon/test_media_player.py @@ -28,7 +28,8 @@ from tests.common import MockConfigEntry, async_fire_time_changed -ENTITY_ID = "media_player.name_of_the_device" +MEDIA_PLAYER_ENTITY_ID = "media_player.name_of_the_device" +DST_SWITCH_ENTITY_ID = "switch.name_of_the_device_daylight_saving_time" _FULL_PLAY_CAPS = ( PlayCaps.PAUSE @@ -81,7 +82,7 @@ async def test_async_media_previous_track_maps_errors( await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, - {ATTR_ENTITY_ID: ENTITY_ID}, + {ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID}, blocking=True, ) @@ -103,7 +104,7 @@ async def test_async_media_caps( await setup_integration(hass, config_entry) - state = hass.states.get(ENTITY_ID) + state = hass.states.get(MEDIA_PLAYER_ENTITY_ID) assert state.attributes[ATTR_SUPPORTED_FEATURES] == ( AFSAPIMediaPlayer._BASE_SUPPORTED_FEATURES | MediaPlayerEntityFeature.PLAY @@ -134,7 +135,7 @@ async def test_media_player_on( device_entry = devices[0] entities = er.async_entries_for_device(entity_registry, device_entry.id) - assert len(entities) == 1 + assert len(entities) == 2 # Power on the device and advance time to trigger a poll mock_afsapi.get_power.return_value = True @@ -142,7 +143,7 @@ async def test_media_player_on( async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(entities[0].entity_id).state == STATE_IDLE + assert hass.states.get(MEDIA_PLAYER_ENTITY_ID).state == STATE_IDLE async def test_async_update_disconnect( @@ -161,22 +162,21 @@ async def test_async_update_disconnect( device_entry = devices[0] entities = er.async_entries_for_device(entity_registry, device_entry.id) - assert len(entities) == 1 - entity_id = entities[0].entity_id + assert len(entities) == 2 # Device starts in off state - assert hass.states.get(entity_id).state == STATE_OFF + assert hass.states.get(MEDIA_PLAYER_ENTITY_ID).state == STATE_OFF # Make the device raise a connection error on the next poll mock_afsapi.get_power.side_effect = FSConnectionError freezer.tick(timedelta(seconds=10)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert hass.states.get(MEDIA_PLAYER_ENTITY_ID).state == STATE_UNAVAILABLE # Reset device error state mock_afsapi.get_power.side_effect = None freezer.tick(timedelta(seconds=10)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_OFF + assert hass.states.get(MEDIA_PLAYER_ENTITY_ID).state == STATE_OFF diff --git a/tests/components/frontier_silicon/test_switch.py b/tests/components/frontier_silicon/test_switch.py new file mode 100644 index 00000000000000..09457d20ec209c --- /dev/null +++ b/tests/components/frontier_silicon/test_switch.py @@ -0,0 +1,160 @@ +"""Test the Frontier Silicon switch entity.""" + +from collections.abc import Generator +from datetime import timedelta +from unittest.mock import AsyncMock + +from afsapi import FSConnectionError, FSNotImplementedError +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed + +DST_SWITCH_ENTITY_ID = "switch.name_of_the_device_daylight_saving_time" + + +@pytest.mark.parametrize( + ("dst_switch_side_effect", "expected_num_entities"), + [(None, 2), (FSNotImplementedError, 1)], +) +async def test_init_with_dst_availability( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_afsapi: AsyncMock, + dst_switch_side_effect: FSNotImplementedError | None, + expected_num_entities: int, +) -> None: + """Test integration setup notices the difference between devices which do or don't implement a DST switch.""" + mock_afsapi.get_dst.side_effect = dst_switch_side_effect + + await setup_integration(hass, config_entry) + + devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) + assert len(devices) == 1 + device_entry = devices[0] + + entities = er.async_entries_for_device(entity_registry, device_entry.id) + assert len(entities) == expected_num_entities + + +async def test_init_device_not_ready( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_afsapi: AsyncMock, +) -> None: + """Test that entity isn't added if there is a connection error.""" + mock_afsapi.get_dst.side_effect = FSConnectionError + + await setup_integration(hass, config_entry) + + devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) + assert len(devices) == 1 + device_entry = devices[0] + + entities = er.async_entries_for_device(entity_registry, device_entry.id) + expected_entities = 1 + assert len(entities) == expected_entities + + +async def test_init_device_not_ready_transient_connection_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_afsapi: AsyncMock, +) -> None: + """Test that entity is added if there is a only a transient connection error.""" + + def transient_connection_error_generator() -> Generator[FSConnectionError | bool]: + """Generate a transient connection error, then always yield a good result.""" + yield FSConnectionError + while True: + yield True + + mock_afsapi.get_dst.side_effect = transient_connection_error_generator() + await setup_integration(hass, config_entry) + + devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) + assert len(devices) == 1 + device_entry = devices[0] + + entities = er.async_entries_for_device(entity_registry, device_entry.id) + expected_entities = 2 + assert len(entities) == expected_entities + + +async def test_dst_switch( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_afsapi: AsyncMock, +) -> None: + """Test turn_on and turn_off for DST switch.""" + + # Set up integration + await setup_integration(hass, config_entry) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: DST_SWITCH_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_afsapi.set_dst.assert_awaited_with(True) + mock_afsapi.set_dst.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: DST_SWITCH_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_afsapi.set_dst.assert_awaited_with(False) + mock_afsapi.set_dst.reset_mock() + + +async def test_dst_switch_get( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_afsapi: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that switch state reflects get_dst result.""" + + await setup_integration(hass, config_entry) + + # Turn DST switch on and advance time to trigger a poll + mock_afsapi.get_dst.return_value = True + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(DST_SWITCH_ENTITY_ID).state == STATE_ON + + # Turn DST switch off and advance time to trigger a poll + mock_afsapi.get_dst.return_value = False + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(DST_SWITCH_ENTITY_ID).state == STATE_OFF diff --git a/tests/components/goalzero/test_config_flow.py b/tests/components/goalzero/test_config_flow.py index a8a8f67bcc1f74..ee55eca6863de0 100644 --- a/tests/components/goalzero/test_config_flow.py +++ b/tests/components/goalzero/test_config_flow.py @@ -45,7 +45,15 @@ async def test_flow_user_already_configured(hass: HomeAssistant) -> None: """Test user initialized flow with duplicate server.""" create_entry(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, ) assert result["type"] is FlowResultType.ABORT @@ -57,7 +65,15 @@ async def test_flow_user_cannot_connect(hass: HomeAssistant) -> None: with patch_config_flow_yeti(await create_mocked_yeti()) as yetimock: yetimock.side_effect = exceptions.ConnectError result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -69,7 +85,15 @@ async def test_flow_user_invalid_host(hass: HomeAssistant) -> None: with patch_config_flow_yeti(await create_mocked_yeti()) as yetimock: yetimock.side_effect = exceptions.InvalidHost result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -81,7 +105,15 @@ async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: with patch_config_flow_yeti(await create_mocked_yeti()) as yetimock: yetimock.side_effect = Exception result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" diff --git a/tests/components/hive/test_config_flow.py b/tests/components/hive/test_config_flow.py index fdfece06dd9d22..f0aa395d43552e 100644 --- a/tests/components/hive/test_config_flow.py +++ b/tests/components/hive/test_config_flow.py @@ -731,9 +731,15 @@ async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={ + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, }, diff --git a/tests/components/hko/test_config_flow.py b/tests/components/hko/test_config_flow.py index 7a2cec961db0d3..9d303de39a0158 100644 --- a/tests/components/hko/test_config_flow.py +++ b/tests/components/hko/test_config_flow.py @@ -37,9 +37,15 @@ async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: with patch("homeassistant.components.hko.config_flow.HKO.weather") as client_mock: client_mock.side_effect = HKOError() result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_LOCATION: DEFAULT_LOCATION}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: DEFAULT_LOCATION}, ) assert result["type"] is FlowResultType.FORM @@ -48,9 +54,15 @@ async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: client_mock.side_effect = None result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_LOCATION: DEFAULT_LOCATION}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: DEFAULT_LOCATION}, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -63,9 +75,15 @@ async def test_config_flow_timeout(hass: HomeAssistant) -> None: with patch("homeassistant.components.hko.config_flow.HKO.weather") as client_mock: client_mock.side_effect = TimeoutError() result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_LOCATION: DEFAULT_LOCATION}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: DEFAULT_LOCATION}, ) assert result["type"] is FlowResultType.FORM @@ -74,9 +92,15 @@ async def test_config_flow_timeout(hass: HomeAssistant) -> None: client_mock.side_effect = None result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_LOCATION: DEFAULT_LOCATION}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: DEFAULT_LOCATION}, ) assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/honeywell/test_config_flow.py b/tests/components/honeywell/test_config_flow.py index ed9c86f5e10381..09d7fbc7938f63 100644 --- a/tests/components/honeywell/test_config_flow.py +++ b/tests/components/honeywell/test_config_flow.py @@ -17,11 +17,10 @@ from tests.common import MockConfigEntry +# The away temperatures are options, not fields on the user form. FAKE_CONFIG = { "username": "fake", "password": "user", - "away_cool_temperature": 88, - "away_heat_temperature": 61, } @@ -40,7 +39,15 @@ async def test_connection_error(hass: HomeAssistant, client: MagicMock) -> None: """Test that an error message is shown on connection fail.""" client.login.side_effect = aiosomecomfort.device.ConnectionError result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=FAKE_CONFIG + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=FAKE_CONFIG, ) assert result["errors"] == {"base": "cannot_connect"} @@ -50,7 +57,15 @@ async def test_auth_error(hass: HomeAssistant, client: MagicMock) -> None: client.login.side_effect = aiosomecomfort.device.AuthError result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=FAKE_CONFIG + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=FAKE_CONFIG, ) assert result["errors"] == {"base": "invalid_auth"} @@ -62,7 +77,15 @@ async def test_create_entry(hass: HomeAssistant) -> None: return_value=True, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=FAKE_CONFIG + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=FAKE_CONFIG, ) await hass.async_block_till_done() diff --git a/tests/components/huawei_lte/test_config_flow.py b/tests/components/huawei_lte/test_config_flow.py index 73dfa0945de783..27975cf5e26b76 100644 --- a/tests/components/huawei_lte/test_config_flow.py +++ b/tests/components/huawei_lte/test_config_flow.py @@ -63,7 +63,14 @@ async def test_show_set_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context=config_entries.ConfigFlowContext(source=config_entries.SOURCE_USER), - data=None, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=None, ) assert result["type"] is FlowResultType.FORM @@ -80,12 +87,23 @@ async def test_urlize_plain_host( result = await hass.config_entries.flow.async_init( DOMAIN, context=config_entries.ConfigFlowContext(source=config_entries.SOURCE_USER), - data=user_input, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert user_input[CONF_URL] == f"http://{host}/" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # The form comes back offering the URL the plain host was turned into + schema = result["data_schema"].schema + url_key = next(key for key in schema if key == CONF_URL) + assert url_key.default() == f"http://{host}/" async def test_already_configured( @@ -113,7 +131,14 @@ async def test_already_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context=config_entries.ConfigFlowContext(source=config_entries.SOURCE_USER), - data=FIXTURE_USER_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=FIXTURE_USER_INPUT, ) assert result["type"] is FlowResultType.ABORT @@ -142,9 +167,15 @@ async def test_connection_errors( """Test we show user form on various errors.""" requests_mock.request(ANY, ANY, exc=exception) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT | data_patch, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=FIXTURE_USER_INPUT | data_patch, ) assert result["type"] is FlowResultType.FORM @@ -256,7 +287,14 @@ async def test_login_error( result = await hass.config_entries.flow.async_init( DOMAIN, context=config_entries.ConfigFlowContext(source=config_entries.SOURCE_USER), - data={**FIXTURE_USER_INPUT, **fixture_override}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={**FIXTURE_USER_INPUT, **fixture_override}, ) assert result["type"] is FlowResultType.FORM @@ -286,7 +324,14 @@ async def test_success(hass: HomeAssistant, login_requests_mock, scheme: str) -> result = await hass.config_entries.flow.async_init( DOMAIN, context=config_entries.ConfigFlowContext(source=config_entries.SOURCE_USER), - data=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, ) await hass.async_block_till_done() diff --git a/tests/components/hvv_departures/test_config_flow.py b/tests/components/hvv_departures/test_config_flow.py index f1d533f4a90701..6fa8ee9fc9e528 100644 --- a/tests/components/hvv_departures/test_config_flow.py +++ b/tests/components/hvv_departures/test_config_flow.py @@ -56,9 +56,15 @@ async def test_user_flow(hass: HomeAssistant) -> None: # step: user result_user = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result_user["type"] is FlowResultType.FORM + assert result_user["step_id"] == "user" + + result_user = await hass.config_entries.flow.async_configure( + result_user["flow_id"], + user_input={ CONF_HOST: "api-test.geofox.de", CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password", @@ -120,9 +126,15 @@ async def test_user_flow_no_results(hass: HomeAssistant) -> None: # step: user result_user = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result_user["type"] is FlowResultType.FORM + assert result_user["step_id"] == "user" + + result_user = await hass.config_entries.flow.async_configure( + result_user["flow_id"], + user_input={ CONF_HOST: "api-test.geofox.de", CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password", @@ -150,9 +162,15 @@ async def test_user_flow_invalid_auth(hass: HomeAssistant) -> None: ): # step: user result_user = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result_user["type"] is FlowResultType.FORM + assert result_user["step_id"] == "user" + + result_user = await hass.config_entries.flow.async_configure( + result_user["flow_id"], + user_input={ CONF_HOST: "api-test.geofox.de", CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password", @@ -172,9 +190,15 @@ async def test_user_flow_cannot_connect(hass: HomeAssistant) -> None: ): # step: user result_user = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result_user["type"] is FlowResultType.FORM + assert result_user["step_id"] == "user" + + result_user = await hass.config_entries.flow.async_configure( + result_user["flow_id"], + user_input={ CONF_HOST: "api-test.geofox.de", CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password", @@ -201,9 +225,15 @@ async def test_user_flow_station(hass: HomeAssistant) -> None: # step: user result_user = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result_user["type"] is FlowResultType.FORM + assert result_user["step_id"] == "user" + + result_user = await hass.config_entries.flow.async_configure( + result_user["flow_id"], + user_input={ CONF_HOST: "api-test.geofox.de", CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password", @@ -235,9 +265,15 @@ async def test_user_flow_station_select(hass: HomeAssistant) -> None: ), ): result_user = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result_user["type"] is FlowResultType.FORM + assert result_user["step_id"] == "user" + + result_user = await hass.config_entries.flow.async_configure( + result_user["flow_id"], + user_input={ CONF_HOST: "api-test.geofox.de", CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password", diff --git a/tests/components/icloud/snapshots/test_calendar.ambr b/tests/components/icloud/snapshots/test_calendar.ambr new file mode 100644 index 00000000000000..39f16e0a7bc5c4 --- /dev/null +++ b/tests/components/icloud/snapshots/test_calendar.ambr @@ -0,0 +1,126 @@ +# serializer version: 1 +# name: test_all_day_event_end_is_exclusive + dict({ + 'calendar.test_icloud_account_personal': dict({ + 'events': list([ + dict({ + 'end': '2024-05-02', + 'start': '2024-05-01', + 'summary': 'Holiday', + }), + ]), + }), + }) +# --- +# name: test_datetime_dates_are_accepted + dict({ + 'calendar.test_icloud_account_personal': dict({ + 'events': list([ + dict({ + 'end': '2024-05-01T11:00:00+02:00', + 'start': '2024-05-01T10:00:00+02:00', + 'summary': 'Naive', + }), + dict({ + 'end': '2024-05-01T11:00:00+00:00', + 'start': '2024-05-01T10:00:00+00:00', + 'summary': 'Aware', + }), + ]), + }), + }) +# --- +# name: test_entities[calendar.test_icloud_account_personal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.test_icloud_account_personal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Personal', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Personal', + 'platform': 'icloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test_account_id_cal1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[calendar.test_icloud_account_personal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test iCloud Account Personal', + }), + 'context': , + 'entity_id': 'calendar.test_icloud_account_personal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_event_uses_its_own_timezone + dict({ + 'calendar.test_icloud_account_personal': dict({ + 'events': list([ + dict({ + 'end': '2024-05-01T11:00:00+02:00', + 'start': '2024-05-01T10:00:00+02:00', + 'summary': 'Meeting', + }), + ]), + }), + }) +# --- +# name: test_get_events_filters_to_the_requested_window + dict({ + 'calendar.test_icloud_account_personal': dict({ + 'events': list([ + dict({ + 'end': '2024-05-01T20:00:00-07:00', + 'start': '2024-05-01T07:00:00-07:00', + 'summary': 'Spanning the window', + }), + dict({ + 'end': '2024-05-01T10:30:00-07:00', + 'start': '2024-05-01T09:30:00-07:00', + 'summary': 'Overlapping the start', + }), + ]), + }), + }) +# --- +# name: test_unknown_event_timezone_falls_back + dict({ + 'calendar.test_icloud_account_personal': dict({ + 'events': list([ + dict({ + 'end': '2024-05-01T11:00:00-07:00', + 'start': '2024-05-01T10:00:00-07:00', + 'summary': 'Meeting', + }), + ]), + }), + }) +# --- diff --git a/tests/components/icloud/test_calendar.py b/tests/components/icloud/test_calendar.py new file mode 100644 index 00000000000000..baf97f35a22f81 --- /dev/null +++ b/tests/components/icloud/test_calendar.py @@ -0,0 +1,402 @@ +"""Tests for the iCloud calendar platform.""" + +from datetime import datetime, timedelta +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pyicloud.exceptions import PyiCloudException +from pyicloud.services.calendar import CalendarObject, EventObject +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.calendar import DOMAIN as CALENDAR_DOMAIN +from homeassistant.components.icloud.coordinator import SCAN_INTERVAL +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from tests.common import ( + AsyncMock, + MockConfigEntry, + async_fire_time_changed, + snapshot_platform, +) + +ENTITY_ID = "calendar.test_icloud_account_personal" + + +def _apple_date(value: datetime) -> list[int]: + """Return a datetime in the wire format pyicloud passes through.""" + return [ + int(value.strftime("%Y%m%d")), + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.hour * 60 + value.minute, + ] + + +def _event( + guid: str, + title: str, + start: datetime, + end: datetime, + *, + pguid: str = "cal1", + all_day: bool = False, + location: str = "", + tz: str = "Floating", +) -> MagicMock: + """Build a mock pyicloud event.""" + event = MagicMock(spec=EventObject) + event.guid = guid + event.pguid = pguid + event.title = title + event.all_day = all_day + event.location = location + event.tz = tz + event.local_start_date = _apple_date(start) + event.local_end_date = _apple_date(end) + event.start_date = event.local_start_date + event.end_date = event.local_end_date + return event + + +def _calendar(guid: str, title: str) -> MagicMock: + """Build a mock pyicloud calendar.""" + calendar = MagicMock(spec=CalendarObject) + calendar.guid = guid + calendar.title = title + return calendar + + +@pytest.fixture(name="calendars") +def mock_calendars(icloud_client: AsyncMock) -> MagicMock: + """Mock the calendar service with one calendar and one event.""" + service = icloud_client.api.calendar + service.get_calendars.return_value = [_calendar("cal1", "Personal")] + service.get_events.return_value = [ + _event( + "ev1", + "Dentist", + datetime(2024, 5, 1, 10, 0), + datetime(2024, 5, 1, 11, 0), + ) + ] + return service + + +async def _setup(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the config entry with only the calendar platform loaded.""" + config_entry.add_to_hass(hass) + with patch("homeassistant.components.icloud.PLATFORMS", [Platform.CALENDAR]): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_entities( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendars: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that a calendar becomes an entity.""" + await _setup(hass, config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_event_in_progress_wins( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendars: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a running event is preferred over a later one.""" + now = dt_util.now() + calendars.get_events.return_value = [ + _event( + "ev1", + "Now", + now.replace(tzinfo=None) - SCAN_INTERVAL, + now.replace(tzinfo=None) + SCAN_INTERVAL, + ), + _event( + "ev2", + "Later", + now.replace(tzinfo=None) + SCAN_INTERVAL * 2, + now.replace(tzinfo=None) + SCAN_INTERVAL * 3, + ), + ] + + await _setup(hass, config_entry) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes["message"] == "Now" + + +async def test_all_day_event_end_is_exclusive( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendars: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test that a single all-day event ends on the following day. + + iCloud reports the same day for start and end, but Home Assistant treats + the end of an all-day event as exclusive. + """ + calendars.get_events.return_value = [ + _event( + "ev1", + "Holiday", + datetime(2024, 5, 1), + datetime(2024, 5, 1), + all_day=True, + ) + ] + + await _setup(hass, config_entry) + + events = await hass.services.async_call( + CALENDAR_DOMAIN, + "get_events", + { + ATTR_ENTITY_ID: ENTITY_ID, + "start_date_time": datetime(2024, 4, 30), + "end_date_time": datetime(2024, 5, 3), + }, + blocking=True, + return_response=True, + ) + assert events == snapshot + + +async def test_get_events_filters_to_the_requested_window( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendars: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test that events outside the requested range are dropped. + + pyicloud sends both bounds as plain dates, so iCloud answers a one-hour + request with everything on the boundary days. + """ + calendars.get_events.return_value = [ + _event( + "ev1", + "Before", + datetime(2024, 5, 1, 8, 0), + datetime(2024, 5, 1, 9, 0), + ), + _event( + "ev2", + "Overlapping the start", + datetime(2024, 5, 1, 9, 30), + datetime(2024, 5, 1, 10, 30), + ), + _event( + "ev3", + "Spanning the window", + datetime(2024, 5, 1, 7, 0), + datetime(2024, 5, 1, 20, 0), + ), + _event( + "ev4", + "After", + datetime(2024, 5, 1, 14, 0), + datetime(2024, 5, 1, 15, 0), + ), + ] + + await _setup(hass, config_entry) + + events = await hass.services.async_call( + CALENDAR_DOMAIN, + "get_events", + { + ATTR_ENTITY_ID: ENTITY_ID, + "start_date_time": datetime(2024, 5, 1, 10, 0), + "end_date_time": datetime(2024, 5, 1, 11, 0), + }, + blocking=True, + return_response=True, + ) + assert events == snapshot + + +async def test_new_calendar_added_on_later_poll( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendars: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a calendar created after setup appears on a later refresh.""" + await _setup(hass, config_entry) + assert hass.states.get("calendar.test_icloud_account_work") is None + + calendars.get_calendars.return_value = [ + _calendar("cal1", "Personal"), + _calendar("cal2", "Work"), + ] + freezer.tick(SCAN_INTERVAL + timedelta(seconds=1)) + async_fire_time_changed(hass) + # The scheduled refresh runs as a background task of the config entry. + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get("calendar.test_icloud_account_work") is not None + + +async def test_get_events_error_raises( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendars: MagicMock, +) -> None: + """Test that an iCloud error surfaces as a Home Assistant error.""" + await _setup(hass, config_entry) + calendars.get_events.side_effect = PyiCloudException("boom") + + with pytest.raises(HomeAssistantError, match="Error fetching events"): + await hass.services.async_call( + CALENDAR_DOMAIN, + "get_events", + { + ATTR_ENTITY_ID: ENTITY_ID, + "start_date_time": datetime(2024, 4, 30), + "end_date_time": datetime(2024, 5, 3), + }, + blocking=True, + return_response=True, + ) + + +async def test_event_uses_its_own_timezone( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendars: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test that an event in another timezone keeps its own instant. + + iCloud reports naive wall-clock times alongside a `tz` field, so assuming + Home Assistant's timezone would place the event at the wrong instant. + """ + calendars.get_events.return_value = [ + _event( + "ev1", + "Meeting", + datetime(2024, 5, 1, 10, 0), + datetime(2024, 5, 1, 11, 0), + tz="Europe/Rome", + ) + ] + + await _setup(hass, config_entry) + + events = await hass.services.async_call( + CALENDAR_DOMAIN, + "get_events", + { + ATTR_ENTITY_ID: ENTITY_ID, + "start_date_time": datetime(2024, 4, 30), + "end_date_time": datetime(2024, 5, 3), + }, + blocking=True, + return_response=True, + ) + assert events == snapshot + + +async def test_datetime_dates_are_accepted( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendars: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test that real datetimes are accepted alongside the wire format. + + pyicloud annotates `EventObject` as holding `datetime` but passes Apple's + wire format through unchanged, so both forms are handled. A naive datetime + is a wall-clock time in the event's own timezone, while an aware one + already names its instant and must keep it. + """ + naive = _event( + "ev1", + "Naive", + datetime(2024, 5, 1, 10, 0), + datetime(2024, 5, 1, 11, 0), + tz="Europe/Rome", + ) + naive.local_start_date = datetime(2024, 5, 1, 10, 0) + naive.local_end_date = datetime(2024, 5, 1, 11, 0) + + aware = _event( + "ev2", + "Aware", + datetime(2024, 5, 1, 10, 0), + datetime(2024, 5, 1, 11, 0), + tz="Europe/Rome", + ) + aware.local_start_date = datetime(2024, 5, 1, 10, 0, tzinfo=dt_util.UTC) + aware.local_end_date = datetime(2024, 5, 1, 11, 0, tzinfo=dt_util.UTC) + + calendars.get_events.return_value = [naive, aware] + + await _setup(hass, config_entry) + + events = await hass.services.async_call( + CALENDAR_DOMAIN, + "get_events", + { + ATTR_ENTITY_ID: ENTITY_ID, + "start_date_time": datetime(2024, 4, 30), + "end_date_time": datetime(2024, 5, 3), + }, + blocking=True, + return_response=True, + ) + assert events == snapshot + + +async def test_unknown_event_timezone_falls_back( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendars: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test that a malformed timezone falls back to Home Assistant's own. + + The event has to survive the fetch rather than be dropped, and its + wall-clock time is read in the default timezone. + """ + calendars.get_events.return_value = [ + _event( + "ev1", + "Meeting", + datetime(2024, 5, 1, 10, 0), + datetime(2024, 5, 1, 11, 0), + tz="Not/AZone", + ) + ] + + await _setup(hass, config_entry) + + events = await hass.services.async_call( + CALENDAR_DOMAIN, + "get_events", + { + ATTR_ENTITY_ID: ENTITY_ID, + "start_date_time": datetime(2024, 4, 30), + "end_date_time": datetime(2024, 5, 3), + }, + blocking=True, + return_response=True, + ) + assert events == snapshot diff --git a/tests/components/iskra/test_config_flow.py b/tests/components/iskra/test_config_flow.py index 7bc438e44c3efe..4d78e6b5970ed4 100644 --- a/tests/components/iskra/test_config_flow.py +++ b/tests/components/iskra/test_config_flow.py @@ -171,9 +171,15 @@ async def test_modbus_abort_if_already_setup( MockConfigEntry(domain=DOMAIN, unique_id=SERIAL).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: HOST, CONF_PROTOCOL: "modbus_tcp"}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: HOST, CONF_PROTOCOL: "modbus_tcp"}, ) assert result["type"] is FlowResultType.FORM @@ -197,9 +203,15 @@ async def test_rest_api_abort_if_already_setup( MockConfigEntry(domain=DOMAIN, unique_id=SERIAL).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: HOST, CONF_PROTOCOL: "rest_api"}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: HOST, CONF_PROTOCOL: "rest_api"}, ) assert result["type"] is FlowResultType.ABORT @@ -225,9 +237,15 @@ async def test_modbus_device_error( mock_pyiskra_modbus.side_effect = s_effect result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: HOST, CONF_PROTOCOL: "modbus_tcp"}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: HOST, CONF_PROTOCOL: "modbus_tcp"}, ) assert result["type"] is FlowResultType.FORM @@ -287,9 +305,15 @@ async def test_rest_device_error( mock_pyiskra_rest.side_effect = s_effect result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: HOST, CONF_PROTOCOL: "rest_api"}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: HOST, CONF_PROTOCOL: "rest_api"}, ) # Test if error returned diff --git a/tests/components/israel_rail/test_config_flow.py b/tests/components/israel_rail/test_config_flow.py index cf32fb041cf12f..3923beeb6d8c90 100644 --- a/tests/components/israel_rail/test_config_flow.py +++ b/tests/components/israel_rail/test_config_flow.py @@ -40,9 +40,15 @@ async def test_flow_fails(hass: HomeAssistant, mock_israelrail: AsyncMock) -> No """Test that the user step fails.""" mock_israelrail.query.side_effect = Exception("error") failed_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert failed_result["type"] is FlowResultType.FORM + assert failed_result["step_id"] == "user" + + failed_result = await hass.config_entries.flow.async_configure( + failed_result["flow_id"], + user_input=VALID_CONFIG, ) assert failed_result["errors"] == {"base": "unknown"} diff --git a/tests/components/iss/test_config_flow.py b/tests/components/iss/test_config_flow.py index 2fa7b63e937f4f..c54572a6487bb6 100644 --- a/tests/components/iss/test_config_flow.py +++ b/tests/components/iss/test_config_flow.py @@ -40,7 +40,7 @@ async def test_integration_already_exists(hass: HomeAssistant) -> None: ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={} + DOMAIN, context={"source": SOURCE_USER} ) assert result.get("type") is FlowResultType.ABORT diff --git a/tests/components/jvc_projector/test_config_flow.py b/tests/components/jvc_projector/test_config_flow.py index fe6ea5634bcfd9..9c2795a4885ca0 100644 --- a/tests/components/jvc_projector/test_config_flow.py +++ b/tests/components/jvc_projector/test_config_flow.py @@ -65,9 +65,19 @@ async def test_user_config_flow_bad_connect_errors( mock_device.connect.side_effect = JvcProjectorTimeoutError result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_PASSWORD: MOCK_PASSWORD, + }, ) assert result["type"] is FlowResultType.FORM @@ -79,9 +89,19 @@ async def test_user_config_flow_bad_connect_errors( mock_device.connect.side_effect = None result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_PASSWORD: MOCK_PASSWORD, + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -97,9 +117,19 @@ async def test_user_config_flow_device_exists_abort( ) -> None: """Test flow aborts when device already configured.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_PASSWORD: MOCK_PASSWORD, + }, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -111,9 +141,15 @@ async def test_user_config_flow_bad_host_errors( ) -> None: """Test errors when bad host error occurs.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: "", CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "", CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, ) assert result["type"] is FlowResultType.FORM @@ -123,9 +159,19 @@ async def test_user_config_flow_bad_host_errors( # Finish flow with success result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_PASSWORD: MOCK_PASSWORD, + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -143,9 +189,19 @@ async def test_user_config_flow_bad_auth_errors( mock_device.connect.side_effect = JvcProjectorAuthError result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_PASSWORD: MOCK_PASSWORD, + }, ) assert result["type"] is FlowResultType.FORM @@ -157,9 +213,19 @@ async def test_user_config_flow_bad_auth_errors( mock_device.connect.side_effect = None result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_PASSWORD: MOCK_PASSWORD, + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/kaco_modbus/__init__.py b/tests/components/kaco_modbus/__init__.py new file mode 100644 index 00000000000000..17e1b0b4e2b89e --- /dev/null +++ b/tests/components/kaco_modbus/__init__.py @@ -0,0 +1,32 @@ +"""Tests for the KACO Modbus integration.""" + +from kaco_modbus.testing import BASE_ADDRESS + +from homeassistant.components.kaco_modbus.const import CONF_UNIT_ID +from homeassistant.const import CONF_HOST, CONF_PORT + +END_OF_CHAIN = 0xFFFF + +MOCK_SERIAL = "8.6TL00000000" +MOCK_MODEL = "blueplanet 8.6 TL3 INT" + +MOCK_USER_INPUT = { + CONF_HOST: "192.168.1.100", + CONF_PORT: 502, + CONF_UNIT_ID: 1, +} + + +def model_registers(image: dict[int, int], model_id: int) -> range: + """Return the registers *model_id* occupies in a captured image. + + Walks the SunSpec model chain rather than hard-coding an offset, so a + test can make one block unreadable and leave the rest answering. + """ + address = BASE_ADDRESS + 2 # past the "SunS" marker + while (found := image[address]) != END_OF_CHAIN: + length = image[address + 1] + if found == model_id: + return range(address, address + 2 + length) + address += 2 + length + raise KeyError(f"model {model_id} is not in this image") diff --git a/tests/components/kaco_modbus/conftest.py b/tests/components/kaco_modbus/conftest.py new file mode 100644 index 00000000000000..411c68daa2cca3 --- /dev/null +++ b/tests/components/kaco_modbus/conftest.py @@ -0,0 +1,103 @@ +"""Common fixtures for the KACO Modbus tests. + +The mock is loaded with a register image captured from a real blueplanet +8.6 TL3 INT, so everything below the connection is the library's own code +reading a real SunSpec map rather than a stubbed device. A test that needs a +different device overrides ``register_image``. +""" + +from collections.abc import AsyncIterator, Generator +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +from kaco_modbus.testing import BLUEPLANET_86TL3 +from modbus_connection import ModbusTcpParams +from modbus_connection.mock import MockModbusConnection, MockModbusUnit +import pytest + +from homeassistant.components.kaco_modbus.const import DOMAIN +from homeassistant.core import HomeAssistant + +from . import MOCK_MODEL, MOCK_SERIAL, MOCK_USER_INPUT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.kaco_modbus.async_setup_entry", + new_callable=AsyncMock, + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def register_image() -> dict[int, int]: + """The registers the mock inverter answers with.""" + return BLUEPLANET_86TL3 + + +@pytest.fixture +def mock_connection(register_image: dict[int, int]) -> MockModbusConnection: + """A fake Modbus TCP connection serving the captured register image.""" + connection = MockModbusConnection() + connection.for_unit(1).load_raw({"holding": dict(register_image)}) + return connection + + +@pytest.fixture +def mock_get_unit(mock_connection: MockModbusConnection) -> Generator[MagicMock]: + """Hand the integration a unit on the mock connection.""" + with patch( + "homeassistant.components.kaco_modbus.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ) as mock_get_unit: + yield mock_get_unit + + +@pytest.fixture +def mock_temporary_unit( + mock_connection: MockModbusConnection, +) -> Generator[MagicMock]: + """Hand the config flow a unit on the mock connection.""" + + @asynccontextmanager + async def _get_unit( + hass: HomeAssistant, params: ModbusTcpParams, unit_id: int + ) -> AsyncIterator[MockModbusUnit]: + yield mock_connection.for_unit(unit_id) + + with patch( + "homeassistant.components.kaco_modbus.config_flow.async_get_temporary_unit", + side_effect=_get_unit, + ) as mock_temporary_unit: + yield mock_temporary_unit + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a KACO Modbus config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=MOCK_SERIAL, + data=MOCK_USER_INPUT, + title=MOCK_MODEL, + ) + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_get_unit: MagicMock, +) -> MockConfigEntry: + """Set up the KACO Modbus integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + return mock_config_entry diff --git a/tests/components/kaco_modbus/snapshots/test_sensor.ambr b/tests/components/kaco_modbus/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..de2540e58c199f --- /dev/null +++ b/tests/components/kaco_modbus/snapshots/test_sensor.ambr @@ -0,0 +1,192 @@ +# serializer version: 1 +# name: test_all_entities[sensor.blueplanet_8_6_tl3_int_ac_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.blueplanet_8_6_tl3_int_ac_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AC power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC power', + 'platform': 'kaco_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_power', + 'unique_id': '8.6TL00000000_ac_power', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.blueplanet_8_6_tl3_int_ac_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'blueplanet 8.6 TL3 INT AC power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.blueplanet_8_6_tl3_int_ac_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000', + }) +# --- +# name: test_all_entities[sensor.blueplanet_8_6_tl3_int_operating_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'sleeping', + 'starting', + 'mppt', + 'throttled', + 'shutting_down', + 'fault', + 'standby', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.blueplanet_8_6_tl3_int_operating_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Operating state', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Operating state', + 'platform': 'kaco_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': '8.6TL00000000_operating_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.blueplanet_8_6_tl3_int_operating_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'blueplanet 8.6 TL3 INT Operating state', + : list([ + 'off', + 'sleeping', + 'starting', + 'mppt', + 'throttled', + 'shutting_down', + 'fault', + 'standby', + ]), + }), + 'context': , + 'entity_id': 'sensor.blueplanet_8_6_tl3_int_operating_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'mppt', + }) +# --- +# name: test_all_entities[sensor.blueplanet_8_6_tl3_int_total_energy_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.blueplanet_8_6_tl3_int_total_energy_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total energy produced', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy produced', + 'platform': 'kaco_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '8.6TL00000000_lifetime_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.blueplanet_8_6_tl3_int_total_energy_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'blueplanet 8.6 TL3 INT Total energy produced', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.blueplanet_8_6_tl3_int_total_energy_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12187.169', + }) +# --- diff --git a/tests/components/kaco_modbus/test_config_flow.py b/tests/components/kaco_modbus/test_config_flow.py new file mode 100644 index 00000000000000..5fe3bbee0340ff --- /dev/null +++ b/tests/components/kaco_modbus/test_config_flow.py @@ -0,0 +1,131 @@ +"""Test the KACO Modbus config flow.""" + +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, _patch, patch + +from kaco_modbus.testing import BLUEPLANET_86TL3, with_manufacturer +from modbus_connection import ModbusTcpParams, ModbusTimeoutError +from modbus_connection.mock import MockModbusConnection, MockModbusUnit +import pytest + +from homeassistant.components.kaco_modbus.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import HomeAssistantError + +from . import MOCK_MODEL, MOCK_SERIAL, MOCK_USER_INPUT + +from tests.common import MockConfigEntry + +TARGET = "homeassistant.components.kaco_modbus.config_flow.async_get_temporary_unit" + + +def _serving(image: dict[int, int]) -> _patch: + """Patch the temporary unit onto a device answering with *image*.""" + connection = MockModbusConnection() + connection.for_unit(1).load_raw({"holding": dict(image)}) + + @asynccontextmanager + async def _get_unit( + hass: HomeAssistant, params: ModbusTcpParams, unit_id: int + ) -> AsyncIterator[MockModbusUnit]: + yield connection.for_unit(unit_id) + + return patch(TARGET, side_effect=_get_unit) + + +def _raising(error: Exception) -> _patch: + """Patch the temporary unit so acquiring it fails.""" + return patch(TARGET, side_effect=error) + + +@pytest.mark.usefixtures("mock_temporary_unit") +async def test_user_step_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test the form renders and a successful flow creates an entry.""" + 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"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_MODEL + assert result["data"] == MOCK_USER_INPUT + # The serial survives an address change, which a host or port does not. + assert result["result"].unique_id == MOCK_SERIAL + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("failure", "expected_error"), + [ + # Another vendor's SunSpec inverter answers the same models at the + # same addresses, so it has to be told apart by its manufacturer. + ( + lambda: _serving(with_manufacturer(BLUEPLANET_86TL3, "Fronius")), + "not_a_kaco_inverter", + ), + ( + lambda: _serving(dict.fromkeys(range(40000, 40010), 0)), + "not_a_sunspec_inverter", + ), + (lambda: _raising(ModbusTimeoutError("no answer")), "cannot_connect"), + # The device is already held on different link settings. + (lambda: _raising(HomeAssistantError("in use")), "cannot_connect"), + (lambda: _raising(RuntimeError("boom")), "unknown"), + ], +) +@pytest.mark.usefixtures("mock_temporary_unit") +async def test_user_step_errors_then_recovers( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + failure: Callable[[], _patch], + expected_error: str, +) -> None: + """Test each failure shows on the form, and the flow still completes.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with failure(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + # The healthy device the fixture serves is back once the failure lifts. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == MOCK_SERIAL + + +@pytest.mark.usefixtures("mock_temporary_unit") +async def test_user_step_aborts_when_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the same inverter cannot be added twice, even at a new address.""" + 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"], {**MOCK_USER_INPUT, "host": "192.168.1.101"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/kaco_modbus/test_init.py b/tests/components/kaco_modbus/test_init.py new file mode 100644 index 00000000000000..e9114a2247459b --- /dev/null +++ b/tests/components/kaco_modbus/test_init.py @@ -0,0 +1,119 @@ +"""Test setting the KACO Modbus entry up, and what happens when it fails.""" + +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from kaco_modbus import SunSpecMapShiftError +from kaco_modbus.testing import BLUEPLANET_86TL3, with_manufacturer +from modbus_connection import ModbusTimeoutError +from modbus_connection.mock import MockModbusConnection +import pytest + +from homeassistant.components.kaco_modbus.const import DOMAIN +from homeassistant.components.kaco_modbus.coordinator import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import MOCK_SERIAL + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test a config entry sets up and unloads.""" + assert init_integration.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() + + assert init_integration.state is ConfigEntryState.NOT_LOADED + + +async def test_device_registry_entry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + init_integration: MockConfigEntry, +) -> None: + """Test the inverter is identified by serial, which survives a move.""" + device = device_registry.async_get_device_by_identifier( + (DOMAIN, MOCK_SERIAL), init_integration.entry_id + ) + + assert device is not None + assert device.manufacturer == "KACO new energy" + assert device.model == "blueplanet 8.6 TL3 INT" + assert device.sw_version == "V5.53" + assert device.serial_number == MOCK_SERIAL + + +@pytest.mark.usefixtures("mock_get_unit") +async def test_a_silent_inverter_retries_and_recovers( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_connection: MockModbusConnection, + mock_config_entry: MockConfigEntry, +) -> None: + """Test an inverter asleep at setup is retried rather than given up on.""" + mock_config_entry.add_to_hass(hass) + unit = mock_connection.for_unit(1) + unit.fail_requests(ModbusTimeoutError("asleep")) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + unit.fail_requests(None) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + "register_image", [with_manufacturer(BLUEPLANET_86TL3, "Fronius")] +) +@pytest.mark.usefixtures("mock_get_unit") +async def test_another_brand_at_the_same_address_fails_permanently( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test a swapped device is a setup error, not an endless retry. + + Retrying cannot make a Fronius into a KACO, so this must not sit in + SETUP_RETRY polling someone else's inverter forever. + """ + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_a_moved_sunspec_map_reloads_the_entry( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + init_integration: MockConfigEntry, +) -> None: + """Test a shifted model chain triggers rediscovery. + + Every bound register offset is stale, so polling on would report + plausible nonsense rather than fail. SunSpecMapShiftError is not a + ModbusError, so it needs handling of its own. + """ + with ( + patch( + "homeassistant.components.kaco_modbus.KacoInverter.async_update_readings", + side_effect=SunSpecMapShiftError("moved"), + ), + patch.object(hass.config_entries, "async_schedule_reload") as reload, + ): + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + reload.assert_called_once_with(init_integration.entry_id) diff --git a/tests/components/kaco_modbus/test_sensor.py b/tests/components/kaco_modbus/test_sensor.py new file mode 100644 index 00000000000000..9b7b8a98201f26 --- /dev/null +++ b/tests/components/kaco_modbus/test_sensor.py @@ -0,0 +1,146 @@ +"""Test the KACO Modbus sensor platform.""" + +from freezegun.api import FrozenDateTimeFactory +from kaco_modbus import KacoInverter +from kaco_modbus.const import INVERTER_MODEL_ID +from kaco_modbus.testing import BLUEPLANET_86TL3, BLUEPLANET_86TL3_ASLEEP +from modbus_connection import ModbusTimeoutError +from modbus_connection.mock import MockModbusConnection +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.kaco_modbus.const import DOMAIN +from homeassistant.components.kaco_modbus.coordinator import SCAN_INTERVAL +from homeassistant.components.kaco_modbus.sensor import SENSOR_DESCRIPTIONS +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import MOCK_SERIAL, model_registers + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +def _entity_id(entity_registry: er.EntityRegistry, key: str) -> str: + """Look the entity up by unique id rather than by a guessed slug.""" + entity_id = entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_{key}" + ) + assert entity_id is not None, f"{key} was not created" + return entity_id + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, +) -> None: + """Test all entities match their snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id) + + +def test_sensor_descriptions_read_real_fields() -> None: + """Guard SENSOR_DESCRIPTIONS against a component transcription slip. + + Values are resolved by getattr, so a wrong component name would other- + wise show up as a permanently empty sensor rather than an error. + """ + device = KacoInverter(MockModbusConnection().for_unit(1)) + for description in SENSOR_DESCRIPTIONS: + assert hasattr(device, description.component), ( + f"unknown component {description.component!r}" + ) + + +async def test_sensor_values( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, +) -> None: + """Test the shipped sensors carry the captured inverter's readings.""" + assert hass.states.get(_entity_id(entity_registry, "ac_power")).state == "1000" + assert ( + hass.states.get(_entity_id(entity_registry, "operating_state")).state == "mppt" + ) + # Reported in Wh and shown in kWh, so the state is the converted value. + energy = hass.states.get(_entity_id(entity_registry, "lifetime_energy")) + assert float(energy.state) == pytest.approx(12187.169) + + +async def test_sensors_go_unavailable_when_the_link_drops( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, + mock_connection: MockModbusConnection, + init_integration: MockConfigEntry, +) -> None: + """Test a silent inverter takes its sensors unavailable, then recovers.""" + power = _entity_id(entity_registry, "ac_power") + unit = mock_connection.for_unit(1) + + unit.fail_requests(ModbusTimeoutError("asleep")) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(power).state == STATE_UNAVAILABLE + + # Recovery must not need a reload: every request connects first. + unit.fail_requests(None) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(power).state == "1000" + + +async def test_an_unreadable_block_takes_its_sensors_unavailable( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, + mock_connection: MockModbusConnection, + init_integration: MockConfigEntry, +) -> None: + """Test a partial poll, where one block fails and the others answer. + + A different path from a dead link: the poll still succeeds, so the entry + stays loaded, and it is the per-component check that takes these entities + unavailable. That the *other* components are unaffected is not observable + until a second component has entities of its own. + """ + power = _entity_id(entity_registry, "ac_power") + assert hass.states.get(power).state == "1000" + + unit = mock_connection.for_unit(1) + for address in model_registers(BLUEPLANET_86TL3, INVERTER_MODEL_ID): + unit.fail_read(address, ModbusTimeoutError("slow block")) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(power).state == STATE_UNAVAILABLE + assert init_integration.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize("register_image", [BLUEPLANET_86TL3_ASLEEP]) +async def test_after_dark_the_inverter_reports_a_true_zero( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, +) -> None: + """Test a sleeping inverter still reports what it genuinely measures. + + A KACO keeps answering after dark rather than going quiet, so nothing + goes unavailable. Producing nothing really is 0 W, and the lifetime + total must keep reporting or the Energy dashboard gains a nightly gap. + """ + assert hass.states.get(_entity_id(entity_registry, "ac_power")).state == "0" + assert ( + hass.states.get(_entity_id(entity_registry, "operating_state")).state + == "sleeping" + ) + energy = hass.states.get(_entity_id(entity_registry, "lifetime_energy")) + assert energy.state != STATE_UNAVAILABLE + assert float(energy.state) > 12000 diff --git a/tests/components/kaleidescape/test_config_flow.py b/tests/components/kaleidescape/test_config_flow.py index ecb5b1640937c3..43e167809ccfda 100644 --- a/tests/components/kaleidescape/test_config_flow.py +++ b/tests/components/kaleidescape/test_config_flow.py @@ -40,7 +40,15 @@ async def test_user_config_flow_bad_connect_errors( mock_device.connect.side_effect = ConnectionError result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: MOCK_HOST} + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: MOCK_HOST}, ) assert result["type"] is FlowResultType.FORM @@ -55,7 +63,15 @@ async def test_user_config_flow_unsupported_device_errors( mock_device.is_server_only = True result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: MOCK_HOST} + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: MOCK_HOST}, ) assert result["type"] is FlowResultType.FORM @@ -67,7 +83,15 @@ async def test_user_config_flow_unsupported_device_errors( async def test_user_config_flow_device_exists_abort(hass: HomeAssistant) -> None: """Test flow aborts when device already configured.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: MOCK_HOST} + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: MOCK_HOST}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/lastfm/test_config_flow.py b/tests/components/lastfm/test_config_flow.py index d49421b04e5038..f97c46f6cd961e 100644 --- a/tests/components/lastfm/test_config_flow.py +++ b/tests/components/lastfm/test_config_flow.py @@ -76,7 +76,15 @@ async def test_flow_fails( """Test user initialized flow with invalid username.""" with patch("pylast.User", return_value=MockUser(thrown_error=error)): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_USER_DATA + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_USER_DATA, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -112,7 +120,15 @@ async def test_flow_hidden_recent_tracks( ), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_USER_DATA + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_USER_DATA, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" diff --git a/tests/components/lcn/test_config_flow.py b/tests/components/lcn/test_config_flow.py index 66e805fdcf4cbd..508fe4251adf13 100644 --- a/tests/components/lcn/test_config_flow.py +++ b/tests/components/lcn/test_config_flow.py @@ -70,7 +70,15 @@ async def test_step_user(hass: HomeAssistant) -> None: ): data = CONNECTION_DATA.copy() result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=data + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=data, ) assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY @@ -89,9 +97,18 @@ async def test_step_user_existing_host( entry.add_to_hass(hass) with patch("homeassistant.components.lcn.PchkConnectionManager.async_connect"): - config_data = entry.data.copy() + # The connection details of the existing entry, as the form asks for them + config_data = {key: entry.data[key] for key in CONNECTION_DATA} result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=config_data + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_data, ) assert result["type"] is data_entry_flow.FlowResultType.ABORT @@ -118,7 +135,15 @@ async def test_step_user_error( data = CONNECTION_DATA.copy() data.update({CONF_HOST: "pchk"}) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=data + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=data, ) assert result["type"] is data_entry_flow.FlowResultType.FORM diff --git a/tests/components/led_infrared/conftest.py b/tests/components/led_infrared/conftest.py index 0728752e08b5fd..774982689a51ab 100644 --- a/tests/components/led_infrared/conftest.py +++ b/tests/components/led_infrared/conftest.py @@ -70,5 +70,9 @@ def mock_infrared_code_to_command() -> Generator[None]: "infrared_protocols.codes.generic.led.Generic44KeyCode.to_command", new=mock_to_command, ), + patch( + "infrared_protocols.codes.generic.led.Generic10KeyCode.to_command", + new=mock_to_command, + ), ): yield diff --git a/tests/components/led_infrared/snapshots/test_button.ambr b/tests/components/led_infrared/snapshots/test_button.ambr index 60efb80e19fad8..d7a5d6476d7c6f 100644 --- a/tests/components/led_infrared/snapshots/test_button.ambr +++ b/tests/components/led_infrared/snapshots/test_button.ambr @@ -1,4 +1,304 @@ # serializer version: 1 +# name: test_setup[generic_10_key][button.led_infrared_via_test_ir_emitter_brightness_down-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_brightness_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Brightness down', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Brightness down', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brightness_down', + 'unique_id': '1234567890_brightness_down', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_10_key][button.led_infrared_via_test_ir_emitter_brightness_down-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Brightness down', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_brightness_down', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_10_key][button.led_infrared_via_test_ir_emitter_brightness_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_brightness_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Brightness up', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Brightness up', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brightness_up', + 'unique_id': '1234567890_brightness_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_10_key][button.led_infrared_via_test_ir_emitter_brightness_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Brightness up', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_brightness_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_10_key][button.led_infrared_via_test_ir_emitter_timer_2h-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_timer_2h', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Timer 2h', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Timer 2h', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'timer_2h', + 'unique_id': '1234567890_timer_2h', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_10_key][button.led_infrared_via_test_ir_emitter_timer_2h-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Timer 2h', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_timer_2h', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_10_key][button.led_infrared_via_test_ir_emitter_timer_4h-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_timer_4h', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Timer 4h', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Timer 4h', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'timer_4h', + 'unique_id': '1234567890_timer_4h', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_10_key][button.led_infrared_via_test_ir_emitter_timer_4h-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Timer 4h', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_timer_4h', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_10_key][button.led_infrared_via_test_ir_emitter_timer_6h-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_timer_6h', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Timer 6h', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Timer 6h', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'timer_6h', + 'unique_id': '1234567890_timer_6h', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_10_key][button.led_infrared_via_test_ir_emitter_timer_6h-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Timer 6h', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_timer_6h', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_10_key][button.led_infrared_via_test_ir_emitter_timer_8h-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_timer_8h', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Timer 8h', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Timer 8h', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'timer_8h', + 'unique_id': '1234567890_timer_8h', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_10_key][button.led_infrared_via_test_ir_emitter_timer_8h-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Timer 8h', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_timer_8h', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_setup[generic_13_key][button.led_infrared_via_test_ir_emitter_brightness_down-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/led_infrared/snapshots/test_diagnostics.ambr b/tests/components/led_infrared/snapshots/test_diagnostics.ambr index a35ed6402aa273..dd77b860332720 100644 --- a/tests/components/led_infrared/snapshots/test_diagnostics.ambr +++ b/tests/components/led_infrared/snapshots/test_diagnostics.ambr @@ -1,8 +1,36 @@ # serializer version: 1 -# name: test_diagnostics +# name: test_diagnostics[generic_10_key] + dict({ + 'device_type': 'generic_10_key', + 'infrared_entity_id': 'infrared.test_ir_emitter', + 'infrared_receiver_entity_id': 'infrared.test_ir_receiver', + }) +# --- +# name: test_diagnostics[generic_13_key] + dict({ + 'device_type': 'generic_13_key', + 'infrared_entity_id': 'infrared.test_ir_emitter', + 'infrared_receiver_entity_id': 'infrared.test_ir_receiver', + }) +# --- +# name: test_diagnostics[generic_24_key] dict({ 'device_type': 'generic_24_key', 'infrared_entity_id': 'infrared.test_ir_emitter', 'infrared_receiver_entity_id': 'infrared.test_ir_receiver', }) # --- +# name: test_diagnostics[generic_40_key] + dict({ + 'device_type': 'generic_40_key', + 'infrared_entity_id': 'infrared.test_ir_emitter', + 'infrared_receiver_entity_id': 'infrared.test_ir_receiver', + }) +# --- +# name: test_diagnostics[generic_44_key] + dict({ + 'device_type': 'generic_44_key', + 'infrared_entity_id': 'infrared.test_ir_emitter', + 'infrared_receiver_entity_id': 'infrared.test_ir_receiver', + }) +# --- diff --git a/tests/components/led_infrared/snapshots/test_event.ambr b/tests/components/led_infrared/snapshots/test_event.ambr index b39fe10891c18a..1f26c659458b7a 100644 --- a/tests/components/led_infrared/snapshots/test_event.ambr +++ b/tests/components/led_infrared/snapshots/test_event.ambr @@ -1,4 +1,80 @@ # serializer version: 1 +# name: test_setup[generic_10_key][event.led_infrared_via_test_ir_emitter_received_command-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'on', + 'off', + 'brightness_up', + 'brightness_down', + 'timer_2h', + 'timer_4h', + 'timer_6h', + 'timer_8h', + 'candle', + 'light', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.led_infrared_via_test_ir_emitter_received_command', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Received command', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Received command', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'received_command', + 'unique_id': '1234567890', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_10_key][event.led_infrared_via_test_ir_emitter_received_command-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : None, + : list([ + 'on', + 'off', + 'brightness_up', + 'brightness_down', + 'timer_2h', + 'timer_4h', + 'timer_6h', + 'timer_8h', + 'candle', + 'light', + ]), + : 'LED Infrared via Test IR emitter Received command', + }), + 'context': , + 'entity_id': 'event.led_infrared_via_test_ir_emitter_received_command', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_setup[generic_13_key][event.led_infrared_via_test_ir_emitter_received_command-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/led_infrared/snapshots/test_light.ambr b/tests/components/led_infrared/snapshots/test_light.ambr index 2368350bd7b0b9..d3010eee2e14bf 100644 --- a/tests/components/led_infrared/snapshots/test_light.ambr +++ b/tests/components/led_infrared/snapshots/test_light.ambr @@ -1,4 +1,73 @@ # serializer version: 1 +# name: test_setup[generic_10_key][light.led_infrared_via_test_ir_emitter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'candle', + 'light', + ]), + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.led_infrared_via_test_ir_emitter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'light', + 'unique_id': '1234567890', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_10_key][light.led_infrared_via_test_ir_emitter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : None, + : None, + : list([ + 'candle', + 'light', + ]), + : 'LED Infrared via Test IR emitter', + : list([ + , + ]), + : , + }), + 'context': , + 'entity_id': 'light.led_infrared_via_test_ir_emitter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_setup[generic_13_key][light.led_infrared_via_test_ir_emitter-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/led_infrared/test_button.py b/tests/components/led_infrared/test_button.py index 1d18aa0f89d3e3..2d9539fc3bb538 100644 --- a/tests/components/led_infrared/test_button.py +++ b/tests/components/led_infrared/test_button.py @@ -4,6 +4,8 @@ from unittest.mock import patch from infrared_protocols.codes.generic.led import ( + BaseGenericLEDCode, + Generic10KeyCode, Generic13KeyCode, Generic24KeyCode, Generic40KeyCode, @@ -42,6 +44,7 @@ def button_only() -> Generator[None]: @pytest.mark.parametrize( "config_entry", [ + LEDIrDeviceType.GENERIC_10_KEY, LEDIrDeviceType.GENERIC_13_KEY, LEDIrDeviceType.GENERIC_24_KEY, LEDIrDeviceType.GENERIC_40_KEY, @@ -205,6 +208,36 @@ async def test_setup( "slow", [Generic44KeyCode.SLOW], ), + ( + LEDIrDeviceType.GENERIC_10_KEY, + "brightness_up", + [Generic10KeyCode.BRIGHTNESS_UP], + ), + ( + LEDIrDeviceType.GENERIC_10_KEY, + "brightness_down", + [Generic10KeyCode.BRIGHTNESS_DOWN], + ), + ( + LEDIrDeviceType.GENERIC_10_KEY, + "timer_2h", + [Generic10KeyCode.TIMER_2H], + ), + ( + LEDIrDeviceType.GENERIC_10_KEY, + "timer_4h", + [Generic10KeyCode.TIMER_4H], + ), + ( + LEDIrDeviceType.GENERIC_10_KEY, + "timer_6h", + [Generic10KeyCode.TIMER_6H], + ), + ( + LEDIrDeviceType.GENERIC_10_KEY, + "timer_8h", + [Generic10KeyCode.TIMER_8H], + ), ], ) @pytest.mark.usefixtures("infrared_codes") @@ -214,9 +247,7 @@ async def test_button_press( entity_registry: er.EntityRegistry, device_type: LEDIrDeviceType, key: str, - expected_codes: list[ - Generic24KeyCode | Generic13KeyCode | Generic40KeyCode | Generic44KeyCode - ], + expected_codes: list[BaseGenericLEDCode], ) -> None: """Test button press action.""" config_entry = MockConfigEntry( diff --git a/tests/components/led_infrared/test_config_flow.py b/tests/components/led_infrared/test_config_flow.py index 28eeadc07a9447..fdd598e7bb707b 100644 --- a/tests/components/led_infrared/test_config_flow.py +++ b/tests/components/led_infrared/test_config_flow.py @@ -22,6 +22,7 @@ @pytest.mark.parametrize( ("device_type", "device_name"), [ + (LEDIrDeviceType.GENERIC_10_KEY, "10-key remote"), (LEDIrDeviceType.GENERIC_13_KEY, "13-key remote"), (LEDIrDeviceType.GENERIC_24_KEY, "24-key remote"), (LEDIrDeviceType.GENERIC_40_KEY, "40-key remote"), diff --git a/tests/components/led_infrared/test_diagnostics.py b/tests/components/led_infrared/test_diagnostics.py index 16f913428770cf..b9a59284335d0a 100644 --- a/tests/components/led_infrared/test_diagnostics.py +++ b/tests/components/led_infrared/test_diagnostics.py @@ -1,7 +1,9 @@ """Test for diagnostics platform of the LED Infrared integration.""" +import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.led_infrared.const import LEDIrDeviceType from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -10,6 +12,17 @@ from tests.typing import ClientSessionGenerator +@pytest.mark.parametrize( + "config_entry", + [ + LEDIrDeviceType.GENERIC_10_KEY, + LEDIrDeviceType.GENERIC_13_KEY, + LEDIrDeviceType.GENERIC_24_KEY, + LEDIrDeviceType.GENERIC_40_KEY, + LEDIrDeviceType.GENERIC_44_KEY, + ], + indirect=True, +) async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/led_infrared/test_event.py b/tests/components/led_infrared/test_event.py index e09a3e330b817b..5ce5b5d8330657 100644 --- a/tests/components/led_infrared/test_event.py +++ b/tests/components/led_infrared/test_event.py @@ -4,6 +4,8 @@ from unittest.mock import patch from infrared_protocols.codes.generic.led import ( + BaseGenericLEDCode, + Generic10KeyCode, Generic13KeyCode, Generic24KeyCode, Generic40KeyCode, @@ -46,6 +48,7 @@ def event_only() -> Generator[None]: @pytest.mark.parametrize( "config_entry", [ + LEDIrDeviceType.GENERIC_10_KEY, LEDIrDeviceType.GENERIC_13_KEY, LEDIrDeviceType.GENERIC_24_KEY, LEDIrDeviceType.GENERIC_40_KEY, @@ -360,6 +363,46 @@ async def test_setup( "on", "light_cyan", ), + (LEDIrDeviceType.GENERIC_10_KEY, Generic10KeyCode.ON, "on", None), + (LEDIrDeviceType.GENERIC_10_KEY, Generic10KeyCode.OFF, "off", None), + (LEDIrDeviceType.GENERIC_10_KEY, Generic10KeyCode.CANDLE, "on", "candle"), + (LEDIrDeviceType.GENERIC_10_KEY, Generic10KeyCode.LIGHT, "on", "light"), + ( + LEDIrDeviceType.GENERIC_10_KEY, + Generic10KeyCode.BRIGHTNESS_UP, + STATE_UNKNOWN, + None, + ), + ( + LEDIrDeviceType.GENERIC_10_KEY, + Generic10KeyCode.BRIGHTNESS_DOWN, + STATE_UNKNOWN, + None, + ), + ( + LEDIrDeviceType.GENERIC_10_KEY, + Generic10KeyCode.TIMER_2H, + STATE_UNKNOWN, + None, + ), + ( + LEDIrDeviceType.GENERIC_10_KEY, + Generic10KeyCode.TIMER_4H, + STATE_UNKNOWN, + None, + ), + ( + LEDIrDeviceType.GENERIC_10_KEY, + Generic10KeyCode.TIMER_6H, + STATE_UNKNOWN, + None, + ), + ( + LEDIrDeviceType.GENERIC_10_KEY, + Generic10KeyCode.TIMER_8H, + STATE_UNKNOWN, + None, + ), ], ) @pytest.mark.usefixtures( @@ -370,10 +413,7 @@ async def test_event( hass: HomeAssistant, mock_infrared_receiver_entity: MockInfraredReceiverEntity, device_type: LEDIrDeviceType, - command_code: Generic13KeyCode - | Generic24KeyCode - | Generic40KeyCode - | Generic44KeyCode, + command_code: BaseGenericLEDCode, expected_light_state: str, expected_light_effect: str | None, ) -> None: diff --git a/tests/components/led_infrared/test_light.py b/tests/components/led_infrared/test_light.py index 3f650c6d6878cb..07eb0e38b4b05e 100644 --- a/tests/components/led_infrared/test_light.py +++ b/tests/components/led_infrared/test_light.py @@ -4,6 +4,8 @@ from unittest.mock import patch from infrared_protocols.codes.generic.led import ( + BaseGenericLEDCode, + Generic10KeyCode, Generic13KeyCode, Generic24KeyCode, Generic40KeyCode, @@ -47,6 +49,7 @@ def light_only() -> Generator[None]: @pytest.mark.parametrize( "config_entry", [ + LEDIrDeviceType.GENERIC_10_KEY, LEDIrDeviceType.GENERIC_13_KEY, LEDIrDeviceType.GENERIC_24_KEY, LEDIrDeviceType.GENERIC_40_KEY, @@ -609,6 +612,30 @@ async def test_setup( {ATTR_EFFECT: "light_cyan"}, [Generic44KeyCode.ON, Generic44KeyCode.LIGHT_CYAN], ), + ( + LEDIrDeviceType.GENERIC_10_KEY, + SERVICE_TURN_ON, + {}, + [Generic10KeyCode.ON], + ), + ( + LEDIrDeviceType.GENERIC_10_KEY, + SERVICE_TURN_OFF, + {}, + [Generic10KeyCode.OFF], + ), + ( + LEDIrDeviceType.GENERIC_10_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "candle"}, + [Generic10KeyCode.ON, Generic10KeyCode.CANDLE], + ), + ( + LEDIrDeviceType.GENERIC_10_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "light"}, + [Generic10KeyCode.ON, Generic10KeyCode.LIGHT], + ), ], ) @pytest.mark.usefixtures("infrared_codes") @@ -618,9 +645,7 @@ async def test_light_actions( device_type: LEDIrDeviceType, service: str, service_data: dict[str, str], - expected_codes: list[ - Generic13KeyCode | Generic24KeyCode | Generic40KeyCode | Generic44KeyCode - ], + expected_codes: list[BaseGenericLEDCode], ) -> None: """Test light actions.""" config_entry = MockConfigEntry( diff --git a/tests/components/lg_netcast/test_config_flow.py b/tests/components/lg_netcast/test_config_flow.py index 3c9a350d6c7716..8ed726874f36ff 100644 --- a/tests/components/lg_netcast/test_config_flow.py +++ b/tests/components/lg_netcast/test_config_flow.py @@ -39,7 +39,15 @@ async def test_user_invalid_host(hass: HomeAssistant) -> None: """Test that errors are shown when the host is invalid.""" with _patch_lg_netcast(): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "invalid/host"} + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "invalid/host"}, ) assert result["errors"] == {CONF_HOST: "invalid_host"} @@ -49,7 +57,15 @@ async def test_manual_host(hass: HomeAssistant) -> None: """Test manual host configuration.""" with _patch_lg_netcast(): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: IP_ADDRESS} + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: IP_ADDRESS}, ) assert result["type"] is data_entry_flow.FlowResultType.FORM @@ -81,7 +97,15 @@ async def test_manual_host_no_connection_during_authorize(hass: HomeAssistant) - """Test manual host configuration.""" with _patch_lg_netcast(fail_connection=True): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: IP_ADDRESS} + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: IP_ADDRESS}, ) assert result["type"] is data_entry_flow.FlowResultType.ABORT @@ -94,7 +118,15 @@ async def test_manual_host_invalid_details_during_authorize( """Test manual host configuration.""" with _patch_lg_netcast(invalid_details=True): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: IP_ADDRESS} + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: IP_ADDRESS}, ) assert result["type"] is data_entry_flow.FlowResultType.ABORT @@ -105,7 +137,15 @@ async def test_manual_host_unsuccessful_details_response(hass: HomeAssistant) -> """Test manual host configuration.""" with _patch_lg_netcast(always_404=True): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: IP_ADDRESS} + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: IP_ADDRESS}, ) assert result["type"] is data_entry_flow.FlowResultType.ABORT @@ -116,7 +156,15 @@ async def test_manual_host_no_unique_id_response(hass: HomeAssistant) -> None: """Test manual host configuration.""" with _patch_lg_netcast(no_unique_id=True): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: IP_ADDRESS} + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: IP_ADDRESS}, ) assert result["type"] is data_entry_flow.FlowResultType.ABORT @@ -127,7 +175,15 @@ async def test_invalid_session_id(hass: HomeAssistant) -> None: """Test Invalid Session ID.""" with _patch_lg_netcast(session_error=True): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: IP_ADDRESS} + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: IP_ADDRESS}, ) assert result["type"] is data_entry_flow.FlowResultType.FORM @@ -166,7 +222,15 @@ def _async_track_time_interval( ): mock_interval.side_effect = _async_track_time_interval result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: IP_ADDRESS} + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: IP_ADDRESS}, ) assert result["type"] is data_entry_flow.FlowResultType.FORM diff --git a/tests/components/lg_thinq/test_init.py b/tests/components/lg_thinq/test_init.py index 9b49bb44be9050..00c742b226b6e3 100644 --- a/tests/components/lg_thinq/test_init.py +++ b/tests/components/lg_thinq/test_init.py @@ -34,6 +34,41 @@ async def test_load_unload_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.parametrize( + "exception", + [ + ThinQAPIException(code="1309", message="Not allowed api call", headers={}), + TypeError(), + ValueError(), + ClientError(), + TimeoutError(), + ], +) +async def test_unload_entry_with_failing_disconnect( + hass: HomeAssistant, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, +) -> None: + """Test the entry unloads even when telling LG we are leaving fails.""" + with patch( + "homeassistant.components.lg_thinq.ThinQMQTT.async_connect", + return_value=True, + ): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mqtt_client = mock_config_entry.runtime_data.mqtt_client + mqtt_client.client = AsyncMock() + mqtt_client.client.async_disconnect.side_effect = exception + + 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 + + @pytest.mark.parametrize( "exception", [AttributeError(), TypeError(), ValueError(), ClientError(), TimeoutError()], diff --git a/tests/components/litejet/test_config_flow.py b/tests/components/litejet/test_config_flow.py index 7b8fa481b697a9..53c5ba55677883 100644 --- a/tests/components/litejet/test_config_flow.py +++ b/tests/components/litejet/test_config_flow.py @@ -28,7 +28,15 @@ async def test_create_entry(hass: HomeAssistant, mock_litejet) -> None: test_data = {CONF_PORT: "/dev/test"} result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=test_data, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -44,10 +52,8 @@ async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: ) first_entry.add_to_hass(hass) - test_data = {CONF_PORT: "/dev/test"} - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data + DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.ABORT @@ -62,7 +68,15 @@ async def test_flow_open_failed(hass: HomeAssistant) -> None: mock_pylitejet.side_effect = SerialException result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=test_data, ) assert result["type"] is FlowResultType.FORM diff --git a/tests/components/litterrobot/test_config_flow.py b/tests/components/litterrobot/test_config_flow.py index ef6c66a1d8379c..9569be1a2ea628 100644 --- a/tests/components/litterrobot/test_config_flow.py +++ b/tests/components/litterrobot/test_config_flow.py @@ -71,9 +71,15 @@ async def test_already_configured(hass: HomeAssistant) -> None: ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=CONFIG[DOMAIN], + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG[DOMAIN], ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index bdbe6501470ba0..7de87400cd4619 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -188,14 +188,15 @@ async def test_already_configured_with_ignored(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={ - CONF_HOST: "1.1.1.1", - CONF_KEYFILE: "", - CONF_CERTFILE: "", - CONF_CA_CERTS: "", - }, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "1.1.1.1"}, ) assert result["type"] is FlowResultType.FORM diff --git a/tests/components/lyngdorf/conftest.py b/tests/components/lyngdorf/conftest.py index f00cf05616f06f..c8114dd077aa91 100644 --- a/tests/components/lyngdorf/conftest.py +++ b/tests/components/lyngdorf/conftest.py @@ -11,8 +11,11 @@ LyngdorfReceiver, NumericControl, NumericRange, + Player, + Remote, RemoteKey, Trim, + ZoneB, ) import pytest @@ -68,6 +71,8 @@ def __new__(cls, value: float, value_range: NumericRange) -> Self: control = super().__new__(cls, value) control.value = value control.range = value_range + control.up = AsyncMock() + control.down = AsyncMock() control.set = AsyncMock() return control @@ -103,6 +108,9 @@ def mock_receiver(mock_create_receiver: MagicMock) -> MagicMock: RemoteKey.DIGIT_0, } ) + remote = MagicMock(spec=Remote) + remote.keys = receiver.available_remote_keys + receiver.remote = remote # Diagnostics reports the whole receiver, so every property it reads # needs a value here; an unset one is a mock the response cannot encode. @@ -110,8 +118,13 @@ def mock_receiver(mock_create_receiver: MagicMock) -> MagicMock: receiver.max_volume = 0.0 receiver.room_perfect_position = "Focus 1" receiver.available_room_perfect_positions = ["Global", "Focus 1"] + receiver.room_perfect_positions = ["Global", "Focus 1"] receiver.voicing = "Neutral" receiver.available_voicings = ["Neutral", "Music", "Movie"] + receiver.voicings = ["Neutral", "Music", "Movie"] + # Sync on the pinned library: they return None rather than a coroutine. + receiver.set_voicing.return_value = None + receiver.set_room_perfect_position.return_value = None receiver.lipsync = None receiver.lipsync_range = NumericRange(0, 500, 1) for _t in ("bass", "treble"): @@ -125,7 +138,10 @@ def mock_receiver(mock_create_receiver: MagicMock) -> MagicMock: receiver.zone_b_volume_range = NumericRange(-99.9, 24.0, 0.1) receiver.power_on = False - receiver.volume = -40.0 + receiver.volume = _FloatControl(-40.0, NumericRange(-99.9, 24.0, 0.1)) + receiver.muted = False + receiver.sources = [] + receiver.sound_modes = [] receiver.mute_enabled = False receiver.source = None receiver.available_sources = [] @@ -138,19 +154,20 @@ def mock_receiver(mock_create_receiver: MagicMock) -> MagicMock: receiver.video_input = "hdmi" receiver.streaming_source = "AirPlay" receiver.available_audio_inputs = ["optical", "aux"] + receiver.audio_inputs = ["optical", "aux"] receiver.available_video_inputs = ["hdmi"] + receiver.video_inputs = ["hdmi"] receiver.available_stream_types = ["AirPlay", "DLNA"] + receiver.stream_types = ["AirPlay", "DLNA"] receiver.now_playing = None receiver.has_position = False receiver.position_ms = None receiver.position_updated_at = None - receiver.shuffle = None - receiver.repeat = None receiver.can_shuffle = False receiver.available_repeat_modes = frozenset() - receiver.lipsync = _FloatControl(50, NumericRange(0, 500, 1)) + receiver.lipsync = _FloatControl(50.0, NumericRange(0, 500, 1)) receiver.lipsync_range = NumericRange(0, 500, 1) receiver.trims = { Trim.BASS: _control(3.0, NumericRange(-12.0, 12.0, 0.1)), @@ -177,8 +194,41 @@ def mock_receiver(mock_create_receiver: MagicMock) -> MagicMock: receiver.zone_b_source = None receiver.zone_b_available_sources = [] receiver.zone_b_audio_input = "aux" + zone_b = MagicMock(spec=ZoneB) + zone_b.audio_input = "aux" + zone_b.streaming_source = "DLNA" + receiver.zone_b = zone_b receiver.zone_b_streaming_source = "DLNA" + receiver.volume = _FloatControl(-40.0, NumericRange(-99.9, 24.0, 0.1)) + receiver.muted = False + receiver.sources = [] + receiver.sound_modes = [] + receiver.audio_inputs = ["optical", "aux"] + receiver.video_inputs = ["hdmi"] + receiver.stream_types = ["AirPlay", "DLNA"] + receiver.room_perfect_positions = ["Global", "Focus 1"] + receiver.voicings = ["Neutral", "Music", "Movie"] + player = MagicMock(spec=Player) + player.now_playing = None + player.position_ms = None + player.position_updated_at = None + player.shuffle = None + player.repeat = None + player.can_shuffle = False + player.repeat_modes = frozenset() + receiver.player = player + + zone_b = MagicMock(spec=ZoneB) + zone_b.power_on = False + zone_b.muted = False + zone_b.source = None + zone_b.audio_input = "aux" + zone_b.streaming_source = "DLNA" + zone_b.sources = [] + zone_b.volume = _FloatControl(-40.0, NumericRange(-99.9, 24.0, 0.1)) + receiver.zone_b = zone_b + mock_create_receiver.return_value = receiver return receiver @@ -217,7 +267,7 @@ def notify_receiver_update(receiver: MagicMock) -> None: def notify_position_jump(receiver: MagicMock, position_ms: int | None) -> None: """Fire every position jump callback the entities registered.""" - for call in receiver.register_position_jump_callback.call_args_list: + for call in receiver.player.on_position_jump.call_args_list: call.args[0](position_ms) diff --git a/tests/components/lyngdorf/snapshots/test_number.ambr b/tests/components/lyngdorf/snapshots/test_number.ambr index cb9740837e01e1..5e7ac2526bada8 100644 --- a/tests/components/lyngdorf/snapshots/test_number.ambr +++ b/tests/components/lyngdorf/snapshots/test_number.ambr @@ -57,7 +57,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '50', + 'state': '50.0', }) # --- # name: test_entities[number.mock_lyngdorf_trim_bass-entry] diff --git a/tests/components/lyngdorf/test_diagnostics.py b/tests/components/lyngdorf/test_diagnostics.py index dd624b59b349c1..9ae84b3919e2b7 100644 --- a/tests/components/lyngdorf/test_diagnostics.py +++ b/tests/components/lyngdorf/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the Lyngdorf diagnostics.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from syrupy.assertion import SnapshotAssertion @@ -34,6 +34,23 @@ async def test_diagnostics( ) == snapshot(exclude=props("entry_id", "created_at", "modified_at")) +async def test_lipsync_range_reported_before_the_device_reports_a_value( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test the lipsync range still reports before the first value arrives.""" + mock_receiver.lipsync = None + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) + + assert diagnostics["state"]["lipsync"] is None + assert diagnostics["ranges"]["lipsync_range"] is not None + + async def test_diagnostics_includes_ssdp_description( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/lyngdorf/test_media_player.py b/tests/components/lyngdorf/test_media_player.py index 4a7ee8b634ed96..732526904d96d3 100644 --- a/tests/components/lyngdorf/test_media_player.py +++ b/tests/components/lyngdorf/test_media_player.py @@ -1,11 +1,13 @@ """Tests for the Lyngdorf media player platform.""" from collections.abc import Generator +from dataclasses import replace from datetime import UTC, datetime +from operator import attrgetter from typing import Any from unittest.mock import MagicMock, patch -from lyngdorf.const import LyngdorfModel +from lyngdorf import LyngdorfModel from lyngdorf.states import Control, PlaybackState, Repeat from lyngdorf.streaming import NowPlaying import pytest @@ -79,7 +81,7 @@ def media_proxy_token() -> Generator[None]: def playing_receiver(mock_receiver: MagicMock) -> MagicMock: """Return a receiver that is streaming a track.""" mock_receiver.power_on = True - mock_receiver.now_playing = NowPlaying( + mock_receiver.player.now_playing = NowPlaying( state=PlaybackState.PLAYING, title="The Killing Moon", artist="Echo & the Bunnymen", @@ -97,13 +99,12 @@ def playing_receiver(mock_receiver: MagicMock) -> MagicMock: ), play_modes=frozenset(), ) - mock_receiver.has_position = True - mock_receiver.position_ms = 318544 - mock_receiver.position_updated_at = POSITION_UPDATED_AT - mock_receiver.shuffle = False - mock_receiver.repeat = Repeat.OFF - mock_receiver.can_shuffle = True - mock_receiver.available_repeat_modes = frozenset({Repeat.OFF, Repeat.ALL}) + mock_receiver.player.position_ms = 318544 + mock_receiver.player.position_updated_at = POSITION_UPDATED_AT + mock_receiver.player.shuffle = False + mock_receiver.player.repeat = Repeat.OFF + mock_receiver.player.can_shuffle = True + mock_receiver.player.repeat_modes = frozenset({Repeat.OFF, Repeat.ALL}) return mock_receiver @@ -142,10 +143,10 @@ async def test_no_zone_b_entity_for_model_without_zone_b( @pytest.mark.parametrize( ("entity_id", "service", "attr", "expected"), [ - (MAIN_ZONE, SERVICE_TURN_ON, "power_on", True), - (MAIN_ZONE, SERVICE_TURN_OFF, "power_on", False), - (ZONE_B, SERVICE_TURN_ON, "zone_b_power_on", True), - (ZONE_B, SERVICE_TURN_OFF, "zone_b_power_on", False), + (MAIN_ZONE, SERVICE_TURN_ON, "set_power", True), + (MAIN_ZONE, SERVICE_TURN_OFF, "set_power", False), + (ZONE_B, SERVICE_TURN_ON, "zone_b.set_power", True), + (ZONE_B, SERVICE_TURN_OFF, "zone_b.set_power", False), ], ) async def test_power( @@ -164,16 +165,16 @@ async def test_power( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert getattr(mock_receiver, attr) is expected + attrgetter(attr)(mock_receiver).assert_awaited_once_with(expected) @pytest.mark.parametrize( ("entity_id", "service", "method"), [ - (MAIN_ZONE, SERVICE_VOLUME_UP, "volume_up"), - (MAIN_ZONE, SERVICE_VOLUME_DOWN, "volume_down"), - (ZONE_B, SERVICE_VOLUME_UP, "zone_b_volume_up"), - (ZONE_B, SERVICE_VOLUME_DOWN, "zone_b_volume_down"), + (MAIN_ZONE, SERVICE_VOLUME_UP, "volume.up"), + (MAIN_ZONE, SERVICE_VOLUME_DOWN, "volume.down"), + (ZONE_B, SERVICE_VOLUME_UP, "zone_b.volume.up"), + (ZONE_B, SERVICE_VOLUME_DOWN, "zone_b.volume.down"), ], ) async def test_volume_step( @@ -191,15 +192,15 @@ async def test_volume_step( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - getattr(mock_receiver, method).assert_called_once() + attrgetter(method)(mock_receiver).assert_awaited_once() @pytest.mark.parametrize( ("entity_id", "level", "method", "expected_db"), [ - (MAIN_ZONE, 0.5, "set_volume", -37.95), - (MAIN_ZONE, 1.0, "set_volume", 24.0), - (ZONE_B, 0.3, "set_zone_b_volume", -62.73), + (MAIN_ZONE, 0.5, "volume.set", -37.95), + (MAIN_ZONE, 1.0, "volume.set", 24.0), + (ZONE_B, 0.3, "zone_b.volume.set", -62.73), ], ) async def test_volume_set( @@ -218,14 +219,16 @@ async def test_volume_set( {ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_LEVEL: level}, blocking=True, ) - getattr(mock_receiver, method).assert_called_once_with(pytest.approx(expected_db)) + attrgetter(method)(mock_receiver).assert_awaited_once_with( + pytest.approx(expected_db) + ) @pytest.mark.parametrize( ("entity_id", "attr"), [ - (MAIN_ZONE, "mute_enabled"), - (ZONE_B, "zone_b_mute_enabled"), + (MAIN_ZONE, "set_muted"), + (ZONE_B, "zone_b.set_muted"), ], ) async def test_mute( @@ -242,14 +245,14 @@ async def test_mute( {ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_MUTED: True}, blocking=True, ) - assert getattr(mock_receiver, attr) is True + attrgetter(attr)(mock_receiver).assert_awaited_once_with(True) @pytest.mark.parametrize( ("entity_id", "attr"), [ - (MAIN_ZONE, "source"), - (ZONE_B, "zone_b_source"), + (MAIN_ZONE, "set_source"), + (ZONE_B, "zone_b.set_source"), ], ) async def test_select_source( @@ -266,7 +269,7 @@ async def test_select_source( {ATTR_ENTITY_ID: entity_id, ATTR_INPUT_SOURCE: "HDMI"}, blocking=True, ) - assert getattr(mock_receiver, attr) == "HDMI" + attrgetter(attr)(mock_receiver).assert_awaited_once_with("HDMI") async def test_select_sound_mode( @@ -281,7 +284,7 @@ async def test_select_sound_mode( {ATTR_ENTITY_ID: MAIN_ZONE, ATTR_SOUND_MODE: "Movie"}, blocking=True, ) - assert mock_receiver.sound_mode == "Movie" + mock_receiver.set_sound_mode.assert_awaited_once_with("Movie") async def test_availability( @@ -312,12 +315,12 @@ async def test_main_zone_state_properties( ) -> None: """Test main zone state properties are reported correctly.""" mock_receiver.power_on = True - mock_receiver.volume = -40.0 - mock_receiver.mute_enabled = False + mock_receiver.volume.value = -40.0 + mock_receiver.muted = False mock_receiver.source = "HDMI" mock_receiver.sound_mode = "Movie" - mock_receiver.available_sources = ["HDMI", "Optical"] - mock_receiver.available_sound_modes = ["Movie", "Stereo"] + mock_receiver.sources = ["HDMI", "Optical"] + mock_receiver.sound_modes = ["Movie", "Stereo"] notify_receiver_update(mock_receiver) await hass.async_block_till_done() @@ -330,7 +333,7 @@ async def test_main_zone_state_properties( assert state.attributes[ATTR_INPUT_SOURCE_LIST] == ["HDMI", "Optical"] assert state.attributes[ATTR_SOUND_MODE_LIST] == ["Movie", "Stereo"] - mock_receiver.volume = None + mock_receiver.volume.value = None notify_receiver_update(mock_receiver) await hass.async_block_till_done() state = hass.states.get(MAIN_ZONE) @@ -349,11 +352,11 @@ async def test_zone_b_state_properties( mock_receiver: MagicMock, ) -> None: """Test zone B state properties are reported correctly.""" - mock_receiver.zone_b_power_on = True - mock_receiver.zone_b_volume = -30.0 - mock_receiver.zone_b_mute_enabled = True - mock_receiver.zone_b_source = "Optical" - mock_receiver.zone_b_available_sources = ["HDMI", "Optical"] + mock_receiver.zone_b.power_on = True + mock_receiver.zone_b.volume.value = -30.0 + mock_receiver.zone_b.muted = True + mock_receiver.zone_b.source = "Optical" + mock_receiver.zone_b.sources = ["HDMI", "Optical"] notify_receiver_update(mock_receiver) await hass.async_block_till_done() @@ -364,6 +367,12 @@ async def test_zone_b_state_properties( assert state.attributes[ATTR_INPUT_SOURCE] == "Optical" assert state.attributes[ATTR_INPUT_SOURCE_LIST] == ["HDMI", "Optical"] + mock_receiver.zone_b.volume.value = None + notify_receiver_update(mock_receiver) + await hass.async_block_till_done() + state = hass.states.get(ZONE_B) + assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is None + async def test_now_playing( hass: HomeAssistant, @@ -393,9 +402,11 @@ async def test_transport_features_absent_when_idle( @pytest.mark.parametrize( ("service", "method"), [ - pytest.param(SERVICE_MEDIA_PAUSE, "async_pause", id="pause"), - pytest.param(SERVICE_MEDIA_NEXT_TRACK, "async_next", id="next"), - pytest.param(SERVICE_MEDIA_PREVIOUS_TRACK, "async_previous", id="previous"), + pytest.param(SERVICE_MEDIA_PAUSE, "player.pause", id="pause"), + pytest.param(SERVICE_MEDIA_NEXT_TRACK, "player.next_track", id="next"), + pytest.param( + SERVICE_MEDIA_PREVIOUS_TRACK, "player.previous_track", id="previous" + ), ], ) @pytest.mark.usefixtures("init_integration") @@ -412,7 +423,7 @@ async def test_transport_actions( {ATTR_ENTITY_ID: MAIN_ZONE}, blocking=True, ) - getattr(playing_receiver, method).assert_awaited_once() + attrgetter(method)(playing_receiver).assert_awaited_once() @pytest.mark.usefixtures("init_integration") @@ -427,7 +438,7 @@ async def test_seek_converts_to_milliseconds( {ATTR_ENTITY_ID: MAIN_ZONE, ATTR_MEDIA_SEEK_POSITION: 42.5}, blocking=True, ) - playing_receiver.async_seek.assert_awaited_once_with(42500) + playing_receiver.player.seek.assert_awaited_once_with(42500) @pytest.mark.usefixtures("init_integration") @@ -437,14 +448,14 @@ async def test_seek_converts_to_milliseconds( pytest.param( SERVICE_SHUFFLE_SET, {ATTR_MEDIA_SHUFFLE: True}, - "async_set_shuffle", + "player.set_shuffle", True, id="shuffle", ), pytest.param( SERVICE_REPEAT_SET, {ATTR_MEDIA_REPEAT: RepeatMode.ALL}, - "async_set_repeat", + "player.set_repeat", Repeat.ALL, id="repeat", ), @@ -466,7 +477,59 @@ async def test_set_play_mode( {ATTR_ENTITY_ID: MAIN_ZONE} | payload, blocking=True, ) - getattr(playing_receiver, method).assert_awaited_once_with(expected) + attrgetter(method)(playing_receiver).assert_awaited_once_with(expected) + + +@pytest.mark.usefixtures("init_integration") +async def test_volume_before_the_device_reports_one( + hass: HomeAssistant, + mock_receiver: MagicMock, +) -> None: + """Test the volume control being absent until the device reports a level.""" + mock_receiver.power_on = True + mock_receiver.volume = None + # Changed alongside so the assertions below fail if building the state + # raised rather than merely omitting the volume. + mock_receiver.muted = True + notify_receiver_update(mock_receiver) + await hass.async_block_till_done() + + state = hass.states.get(MAIN_ZONE) + assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is True + assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is None + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_UP, + {ATTR_ENTITY_ID: MAIN_ZONE}, + blocking=True, + ) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: MAIN_ZONE, ATTR_MEDIA_VOLUME_LEVEL: 0.5}, + blocking=True, + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_transport_features_follow_the_source( + hass: HomeAssistant, + playing_receiver: MagicMock, +) -> None: + """Test only the controls the current source offers are advertised.""" + now_playing = playing_receiver.player.now_playing + playing_receiver.player.now_playing = replace( + now_playing, controls=frozenset({Control.PAUSE}) + ) + notify_receiver_update(playing_receiver) + await hass.async_block_till_done() + + features = hass.states.get(MAIN_ZONE).attributes[ATTR_SUPPORTED_FEATURES] + assert features & MediaPlayerEntityFeature.PAUSE + assert not features & MediaPlayerEntityFeature.SEEK + assert not features & MediaPlayerEntityFeature.NEXT_TRACK + assert not features & MediaPlayerEntityFeature.PREVIOUS_TRACK @pytest.mark.usefixtures("init_integration") @@ -475,7 +538,7 @@ async def test_no_streaming_features_on_model_without_streamer( playing_receiver: MagicMock, ) -> None: """Test a model with no streaming module offers no transport.""" - playing_receiver.model = LyngdorfModel.TDAI_2170 + playing_receiver.player = None notify_receiver_update(playing_receiver) await hass.async_block_till_done() @@ -492,12 +555,14 @@ async def test_no_position_before_the_streamer_reports_one( playing_receiver: MagicMock, ) -> None: """Test an attached player that has not yet reported a position.""" - playing_receiver.position_ms = None + playing_receiver.player.position_ms = None notify_receiver_update(playing_receiver) await hass.async_block_till_done() state = hass.states.get(MAIN_ZONE) assert state.attributes.get(ATTR_MEDIA_POSITION) is None + # The timestamp advances on every poll, so it must not be published alone. + assert state.attributes.get(ATTR_MEDIA_POSITION_UPDATED_AT) is None @pytest.mark.usefixtures("init_integration") @@ -506,7 +571,7 @@ async def test_position_jump_updates_state( playing_receiver: MagicMock, ) -> None: """Test a position discontinuity refreshes the reported position.""" - playing_receiver.position_ms = 1000 + playing_receiver.player.position_ms = 1000 notify_position_jump(playing_receiver, 1000) await hass.async_block_till_done() diff --git a/tests/components/lyngdorf/test_remote.py b/tests/components/lyngdorf/test_remote.py index a16db247b97053..56d4f002bc7099 100644 --- a/tests/components/lyngdorf/test_remote.py +++ b/tests/components/lyngdorf/test_remote.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch -from lyngdorf.const import LyngdorfModel +from lyngdorf import LyngdorfModel from lyngdorf.exceptions import LyngdorfUnsupportedError from lyngdorf.remote import RemoteKey, resolve_remote_key import pytest @@ -61,7 +61,7 @@ async def test_send_command( blocking=True, ) - mock_receiver.send_remote_commands.assert_called_once_with( + mock_receiver.remote.send.assert_awaited_once_with( [RemoteKey.MENU, RemoteKey.DOWN, RemoteKey.ENTER], num_repeats=1 ) @@ -83,9 +83,7 @@ async def test_send_command_repeats( blocking=True, ) - mock_receiver.send_remote_commands.assert_called_once_with( - [RemoteKey.DOWN], num_repeats=3 - ) + mock_receiver.remote.send.assert_awaited_once_with([RemoteKey.DOWN], num_repeats=3) @pytest.mark.usefixtures("init_integration") @@ -94,9 +92,7 @@ async def test_send_unsupported_command( mock_receiver: MagicMock, ) -> None: """Test a key the model does not have is reported to the user.""" - mock_receiver.send_remote_commands.side_effect = LyngdorfUnsupportedError( - "no such key" - ) + mock_receiver.remote.send.side_effect = LyngdorfUnsupportedError("no such key") with pytest.raises(ServiceValidationError) as err: await hass.services.async_call( @@ -135,7 +131,7 @@ async def test_power( blocking=True, ) - assert mock_receiver.power_on is expected + mock_receiver.set_power.assert_awaited_once_with(expected) @pytest.mark.usefixtures("mock_receiver") @@ -146,7 +142,7 @@ async def test_no_entity_for_model_without_remote_keys( entity_registry: er.EntityRegistry, ) -> None: """Test no remote entity is created for a model with no remote keys.""" - mock_receiver.has_remote_keys = False + mock_receiver.remote = None mock_config_entry.add_to_hass(hass) with ( diff --git a/tests/components/lyngdorf/test_select.py b/tests/components/lyngdorf/test_select.py index cbd4526bad6964..6815087cedc358 100644 --- a/tests/components/lyngdorf/test_select.py +++ b/tests/components/lyngdorf/test_select.py @@ -1,6 +1,6 @@ """Tests for the Lyngdorf select platform.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest from syrupy.assertion import SnapshotAssertion @@ -46,7 +46,7 @@ async def test_room_perfect_select_option( ) -> None: """Test selecting a RoomPerfect position.""" mock_receiver.room_perfect_position = "focus" - mock_receiver.available_room_perfect_positions = ["focus", "global"] + mock_receiver.room_perfect_positions = ["focus", "global"] notify_receiver_update(mock_receiver) await hass.async_block_till_done() @@ -64,6 +64,28 @@ async def test_room_perfect_select_option( mock_receiver.set_room_perfect_position.assert_called_once_with("global") +async def test_select_option_awaits_an_awaitable_setter( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test a setter that returns a coroutine is awaited rather than dropped.""" + mock_receiver.set_voicing = AsyncMock() + mock_receiver.voicing = "Neutral" + mock_receiver.voicings = ["Neutral", "Music", "Movie"] + notify_receiver_update(mock_receiver) + await hass.async_block_till_done() + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: VOICING_ENTITY_ID, ATTR_OPTION: "Music"}, + blocking=True, + ) + + mock_receiver.set_voicing.assert_awaited_once_with("Music") + + async def test_voicing_select_option( hass: HomeAssistant, init_integration: MockConfigEntry, @@ -71,7 +93,7 @@ async def test_voicing_select_option( ) -> None: """Test selecting a voicing.""" mock_receiver.voicing = "Neutral" - mock_receiver.available_voicings = ["Neutral", "Music", "Movie"] + mock_receiver.voicings = ["Neutral", "Music", "Movie"] notify_receiver_update(mock_receiver) await hass.async_block_till_done() diff --git a/tests/components/lyngdorf/test_sensor.py b/tests/components/lyngdorf/test_sensor.py index 1ad2e09b847df9..23efd081437498 100644 --- a/tests/components/lyngdorf/test_sensor.py +++ b/tests/components/lyngdorf/test_sensor.py @@ -52,7 +52,7 @@ async def test_enum_sensor_ignores_unknown_device_value( mock_receiver: MagicMock, ) -> None: """Test an input the library could not name is reported as unknown.""" - mock_receiver.available_audio_inputs = ["optical"] + mock_receiver.audio_inputs = ["optical"] mock_receiver.audio_input = "audio-37" notify_receiver_update(mock_receiver) @@ -61,13 +61,63 @@ async def test_enum_sensor_ignores_unknown_device_value( assert hass.states.get("sensor.mock_lyngdorf_audio_input").state == STATE_UNKNOWN +@pytest.mark.parametrize( + ("entity_id", "attribute", "value"), + [ + pytest.param( + "sensor.mock_lyngdorf_video_input", "video_inputs", ["DP"], id="video" + ), + pytest.param( + "sensor.mock_lyngdorf_streaming_source", + "stream_types", + ["Spotify"], + id="stream", + ), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_sensors_read_the_current_lists_not_the_deprecated_aliases( + hass: HomeAssistant, + mock_receiver: MagicMock, + entity_id: str, + attribute: str, + value: list[str], +) -> None: + """Test the lists come from the 2.0 names while the aliases say otherwise.""" + setattr(mock_receiver, attribute, value) + notify_receiver_update(mock_receiver) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).attributes["options"] == value + + +@pytest.mark.usefixtures("init_integration") +async def test_zone_b_sensors_read_the_zone_object( + hass: HomeAssistant, + mock_receiver: MagicMock, +) -> None: + """Test the Zone B sensors follow the zone, not the receiver aliases.""" + mock_receiver.zone_b.audio_input = "optical" + mock_receiver.zone_b.streaming_source = "AirPlay" + mock_receiver.zone_b_audio_input = "aux" + mock_receiver.zone_b_streaming_source = "DLNA" + notify_receiver_update(mock_receiver) + await hass.async_block_till_done() + + assert hass.states.get("sensor.mock_lyngdorf_zone_b_audio_input").state == "optical" + assert ( + hass.states.get("sensor.mock_lyngdorf_zone_b_streaming_source").state + == "AirPlay" + ) + + @pytest.mark.usefixtures("init_integration") async def test_enum_options_follow_the_device( hass: HomeAssistant, mock_receiver: MagicMock, ) -> None: """Test enum options track the lists the device reports.""" - mock_receiver.available_audio_inputs = ["HDMI", "optical"] + mock_receiver.audio_inputs = ["HDMI", "optical"] mock_receiver.audio_input = "HDMI" notify_receiver_update(mock_receiver) await hass.async_block_till_done() @@ -75,7 +125,7 @@ async def test_enum_options_follow_the_device( state = hass.states.get("sensor.mock_lyngdorf_audio_input") assert state.attributes["options"] == ["HDMI", "optical"] - mock_receiver.available_audio_inputs = ["HDMI", "optical", "ARC"] + mock_receiver.audio_inputs = ["HDMI", "optical", "ARC"] notify_receiver_update(mock_receiver) await hass.async_block_till_done() diff --git a/tests/components/mcp_server/conftest.py b/tests/components/mcp_server/conftest.py index 40b82c2a2301ad..e318713cf7bfdb 100644 --- a/tests/components/mcp_server/conftest.py +++ b/tests/components/mcp_server/conftest.py @@ -5,7 +5,7 @@ import pytest -from homeassistant.components.mcp_server.const import DOMAIN +from homeassistant.components.mcp_server.const import CONF_REQUIRE_ADMIN, DOMAIN from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.helpers import llm @@ -52,16 +52,24 @@ def llm_hass_api_fixture() -> list[str]: return [llm.LLM_API_ASSIST] +@pytest.fixture(name="require_admin") +def require_admin_fixture() -> bool: + """Fixture for the config entry require admin option.""" + return False + + @pytest.fixture(name="config_entry") def mock_config_entry( - hass: HomeAssistant, llm_hass_api: str | list[str] + hass: HomeAssistant, llm_hass_api: str | list[str], require_admin: bool ) -> MockConfigEntry: """Fixture to load the integration.""" config_entry = MockConfigEntry( domain=DOMAIN, data={ CONF_LLM_HASS_API: llm_hass_api, + CONF_REQUIRE_ADMIN: require_admin, }, + minor_version=2, ) config_entry.add_to_hass(hass) return config_entry diff --git a/tests/components/mcp_server/test_config_flow.py b/tests/components/mcp_server/test_config_flow.py index 68aa099d228402..18b4255244eeb5 100644 --- a/tests/components/mcp_server/test_config_flow.py +++ b/tests/components/mcp_server/test_config_flow.py @@ -6,7 +6,8 @@ import pytest from homeassistant import config_entries -from homeassistant.components.mcp_server.const import DOMAIN +from homeassistant.components.mcp_server.const import CONF_REQUIRE_ADMIN, DOMAIN +from homeassistant.config_entries import ConfigEntryDisabler from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -43,7 +44,11 @@ async def test_form( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Assist" assert len(mock_setup_entry.mock_calls) == 1 - assert result["data"] == {CONF_LLM_HASS_API: ["assist"]} + assert result["minor_version"] == 2 + assert result["data"] == { + CONF_LLM_HASS_API: ["assist"], + CONF_REQUIRE_ADMIN: True, + } @pytest.mark.parametrize( @@ -84,17 +89,24 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" assert not result["errors"] - assert result["data_schema"]({}) == {CONF_LLM_HASS_API: [llm.LLM_API_ASSIST]} + assert result["data_schema"]({}) == { + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_REQUIRE_ADMIN: False, + } result = await hass.config_entries.options.async_configure( result["flow_id"], - {CONF_LLM_HASS_API: [llm.LLM_API_ASSIST, TEST_LLM_API_ID]}, + { + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST, TEST_LLM_API_ID], + CONF_REQUIRE_ADMIN: True, + }, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert config_entry.data == { - CONF_LLM_HASS_API: [llm.LLM_API_ASSIST, TEST_LLM_API_ID] + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST, TEST_LLM_API_ID], + CONF_REQUIRE_ADMIN: True, } assert config_entry.title == "Assist, Test" @@ -109,16 +121,22 @@ async def test_options_flow_legacy_single_api( result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["data_schema"]({}) == {CONF_LLM_HASS_API: [llm.LLM_API_ASSIST]} + assert result["data_schema"]({}) == { + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_REQUIRE_ADMIN: False, + } result = await hass.config_entries.options.async_configure( result["flow_id"], - {CONF_LLM_HASS_API: [llm.LLM_API_ASSIST]}, + {CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], CONF_REQUIRE_ADMIN: False}, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.data == {CONF_LLM_HASS_API: [llm.LLM_API_ASSIST]} + assert config_entry.data == { + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_REQUIRE_ADMIN: False, + } async def test_options_flow_keeps_custom_title( @@ -132,12 +150,15 @@ async def test_options_flow_keeps_custom_title( result = await hass.config_entries.options.async_init(config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], - {CONF_LLM_HASS_API: [TEST_LLM_API_ID]}, + {CONF_LLM_HASS_API: [TEST_LLM_API_ID], CONF_REQUIRE_ADMIN: False}, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.data == {CONF_LLM_HASS_API: [TEST_LLM_API_ID]} + assert config_entry.data == { + CONF_LLM_HASS_API: [TEST_LLM_API_ID], + CONF_REQUIRE_ADMIN: False, + } assert config_entry.title == "My MCP server" @@ -152,7 +173,7 @@ async def test_options_flow_errors( result = await hass.config_entries.options.async_configure( result["flow_id"], - {CONF_LLM_HASS_API: []}, + {CONF_LLM_HASS_API: [], CONF_REQUIRE_ADMIN: False}, ) assert result["type"] is FlowResultType.FORM @@ -160,9 +181,39 @@ async def test_options_flow_errors( result = await hass.config_entries.options.async_configure( result["flow_id"], - {CONF_LLM_HASS_API: [llm.LLM_API_ASSIST]}, + {CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], CONF_REQUIRE_ADMIN: False}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.data == { + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_REQUIRE_ADMIN: False, + } + + +async def test_options_flow_unmigrated_entry(hass: HomeAssistant) -> None: + """Test the options flow on a disabled config entry that has not migrated.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_LLM_HASS_API: [llm.LLM_API_ASSIST]}, + minor_version=1, + disabled_by=ConfigEntryDisabler.USER, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["data_schema"]({}) == { + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_REQUIRE_ADMIN: False, + } + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], CONF_REQUIRE_ADMIN: True}, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.data == {CONF_LLM_HASS_API: [llm.LLM_API_ASSIST]} + assert config_entry.data[CONF_REQUIRE_ADMIN] is True diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index d908cc5177b4cf..a06332ab0a50fd 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -735,3 +735,62 @@ async def test_streamable_api_id_unknown( ) assert response.status == HTTPStatus.NOT_FOUND assert "Unknown LLM API" in await response.text() + + +@pytest.mark.parametrize( + ("require_admin", "expected_status"), + [ + pytest.param(False, HTTPStatus.OK, id="not_required"), + pytest.param(True, HTTPStatus.UNAUTHORIZED, id="required"), + ], +) +async def test_require_admin_option( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, + hass_read_only_access_token: str, + expected_status: HTTPStatus, +) -> None: + """Test the require admin option applied to a non-admin user.""" + client = await hass_client(hass_read_only_access_token) + + response = await client.post( + STREAMABLE_API, + json=INITIALIZE_MESSAGE, + headers={"accept": CONTENT_TYPE_JSON}, + ) + assert response.status == expected_status + + +@pytest.mark.parametrize("require_admin", [True]) +async def test_require_admin_blocks_sse_endpoints( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, + hass_read_only_access_token: str, +) -> None: + """Test the require admin option applied to the SSE endpoints.""" + client = await hass_client(hass_read_only_access_token) + + response = await client.get(SSE_API) + assert response.status == HTTPStatus.UNAUTHORIZED + + response = await client.post(MESSAGES_API.format(session_id="session-id")) + assert response.status == HTTPStatus.UNAUTHORIZED + + +@pytest.mark.parametrize("require_admin", [True]) +async def test_require_admin_allows_admin( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test an admin user may use the endpoint that requires an admin.""" + client = await hass_client() + + response = await client.post( + STREAMABLE_API, + json=INITIALIZE_MESSAGE, + headers={"accept": CONTENT_TYPE_JSON}, + ) + assert response.status == HTTPStatus.OK diff --git a/tests/components/mcp_server/test_init.py b/tests/components/mcp_server/test_init.py index af6a8a55e595f4..501fc49d3838fc 100644 --- a/tests/components/mcp_server/test_init.py +++ b/tests/components/mcp_server/test_init.py @@ -1,7 +1,10 @@ """Test the Model Context Protocol Server init module.""" +from homeassistant.components.mcp_server.const import CONF_REQUIRE_ADMIN, DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm from tests.common import MockConfigEntry @@ -13,3 +16,38 @@ async def test_init(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: await hass.config_entries.async_unload(config_entry.entry_id) assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_migrate_entry_require_admin(hass: HomeAssistant) -> None: + """Test an entry created before the require admin option keeps the endpoints open.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_LLM_HASS_API: [llm.LLM_API_ASSIST]}, + minor_version=1, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + assert config_entry.minor_version == 2 + assert config_entry.data == { + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_REQUIRE_ADMIN: False, + } + + +async def test_migrate_entry_keeps_require_admin(hass: HomeAssistant) -> None: + """Test the migration keeps an option the options flow saved before it ran.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], CONF_REQUIRE_ADMIN: True}, + minor_version=1, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + assert config_entry.minor_version == 2 + assert config_entry.data[CONF_REQUIRE_ADMIN] is True diff --git a/tests/components/media_extractor/test_config_flow.py b/tests/components/media_extractor/test_config_flow.py index 786341fd553550..7bb2ba7a1207da 100644 --- a/tests/components/media_extractor/test_config_flow.py +++ b/tests/components/media_extractor/test_config_flow.py @@ -36,7 +36,7 @@ async def test_single_instance_allowed(hass: HomeAssistant) -> None: mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={} + DOMAIN, context={"source": SOURCE_USER} ) assert result.get("type") is FlowResultType.ABORT diff --git a/tests/components/met_eireann/test_config_flow.py b/tests/components/met_eireann/test_config_flow.py index cddc20b835aff9..26ae271ef361a7 100644 --- a/tests/components/met_eireann/test_config_flow.py +++ b/tests/components/met_eireann/test_config_flow.py @@ -64,7 +64,15 @@ async def test_create_entry(hass: HomeAssistant) -> None: } result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=test_data, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -86,13 +94,29 @@ async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: # Create the first entry and assert that it is created successfully result1 = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user" + + result1 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input=test_data, ) assert result1["type"] is FlowResultType.CREATE_ENTRY # Create the second entry and assert that it is aborted result2 = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input=test_data, ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" diff --git a/tests/components/meteoclimatic/test_config_flow.py b/tests/components/meteoclimatic/test_config_flow.py index ff9de358e8695f..c147247c83ef10 100644 --- a/tests/components/meteoclimatic/test_config_flow.py +++ b/tests/components/meteoclimatic/test_config_flow.py @@ -48,10 +48,9 @@ async def test_user(hass: HomeAssistant, client) -> None: assert result["step_id"] == "user" # test with all provided - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_STATION_CODE: TEST_STATION_CODE}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_STATION_CODE: TEST_STATION_CODE}, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].unique_id == TEST_STATION_CODE @@ -66,9 +65,15 @@ async def test_not_found(hass: HomeAssistant) -> None: side_effect=StationNotFound(TEST_STATION_CODE), ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_STATION_CODE: TEST_STATION_CODE}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_STATION_CODE: TEST_STATION_CODE}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -82,9 +87,15 @@ async def test_unknown_error(hass: HomeAssistant) -> None: side_effect=MeteoclimaticError, ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_STATION_CODE: TEST_STATION_CODE}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_STATION_CODE: TEST_STATION_CODE}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unknown" diff --git a/tests/components/metoffice/test_config_flow.py b/tests/components/metoffice/test_config_flow.py index 8488757e0f9064..ac04d6fa9b5cdd 100644 --- a/tests/components/metoffice/test_config_flow.py +++ b/tests/components/metoffice/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant import config_entries from homeassistant.components.metoffice.const import DOMAIN -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr @@ -86,9 +86,19 @@ async def test_form_already_configured( ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=METOFFICE_CONFIG_WAVERTREE, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: TEST_API_KEY, + CONF_LATITUDE: TEST_LATITUDE_WAVERTREE, + CONF_LONGITUDE: TEST_LONGITUDE_WAVERTREE, + }, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 254e156f1f00f0..8f966ea483156c 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -50,8 +50,9 @@ async def test_full_flow_java(hass: HomeAssistant) -> None: return_value=TEST_JAVA_STATUS_RESPONSE, ), ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -79,8 +80,9 @@ async def test_full_flow_bedrock(hass: HomeAssistant) -> None: return_value=TEST_BEDROCK_STATUS_RESPONSE, ), ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -116,8 +118,9 @@ async def test_full_flow_legacy_java(hass: HomeAssistant) -> None: return_value=TEST_LEGACY_JAVA_STATUS_RESPONSE, ), ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -147,7 +150,15 @@ async def test_service_already_configured_java( ), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -170,7 +181,15 @@ async def test_service_already_configured_bedrock( ), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -201,7 +220,15 @@ async def test_service_already_configured_legacy_java( ), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -228,7 +255,15 @@ async def test_recovery_java(hass: HomeAssistant) -> None: ), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} @@ -277,7 +312,15 @@ async def test_recovery_bedrock(hass: HomeAssistant) -> None: ), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} @@ -322,7 +365,15 @@ async def test_recovery_legacy_java(hass: HomeAssistant) -> None: ), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/moehlenhoff_alpha2/test_config_flow.py b/tests/components/moehlenhoff_alpha2/test_config_flow.py index dd96165ae3962a..bf69b30ef634c7 100644 --- a/tests/components/moehlenhoff_alpha2/test_config_flow.py +++ b/tests/components/moehlenhoff_alpha2/test_config_flow.py @@ -60,9 +60,15 @@ async def test_form_duplicate_error(hass: HomeAssistant) -> None: partialmethod(mock_update_data, hass), ): result = await hass.config_entries.flow.async_init( - DOMAIN, - data={"host": MOCK_BASE_HOST}, - context={"source": config_entries.SOURCE_USER}, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"host": MOCK_BASE_HOST}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/motionmount/test_config_flow.py b/tests/components/motionmount/test_config_flow.py index f6c5e8d8cc3161..5aa83ddededf1b 100644 --- a/tests/components/motionmount/test_config_flow.py +++ b/tests/components/motionmount/test_config_flow.py @@ -43,9 +43,15 @@ async def test_user_connection_error( user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, ) assert result["type"] is FlowResultType.ABORT @@ -62,9 +68,15 @@ async def test_user_connection_error_invalid_hostname( user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, ) assert result["type"] is FlowResultType.ABORT @@ -81,9 +93,15 @@ async def test_user_timeout_error( user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, ) assert result["type"] is FlowResultType.ABORT @@ -100,9 +118,15 @@ async def test_user_not_connected_error( user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, ) assert result["type"] is FlowResultType.ABORT @@ -120,9 +144,15 @@ async def test_user_response_error_single_device_new_ce_old_pro( user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -146,9 +176,15 @@ async def test_user_response_error_single_device_new_ce_new_pro( user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -176,9 +212,15 @@ async def test_user_response_error_multi_device_new_ce_new_pro( user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, ) assert result["type"] is FlowResultType.ABORT @@ -198,9 +240,15 @@ async def test_user_response_authentication_needed( user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, ) assert result["type"] is FlowResultType.FORM @@ -443,9 +491,15 @@ async def test_authentication_incorrect_then_correct_pin( user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, ) assert result["type"] is FlowResultType.FORM @@ -493,9 +547,15 @@ async def test_authentication_first_incorrect_pin_to_backoff( type(mock_motionmount).can_authenticate = PropertyMock(side_effect=[True, 1]) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=MOCK_USER_INPUT.copy(), + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_INPUT.copy(), ) assert result["type"] is FlowResultType.FORM @@ -549,9 +609,15 @@ async def test_authentication_multiple_incorrect_pins( user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, ) assert result["type"] is FlowResultType.FORM @@ -602,9 +668,15 @@ async def test_authentication_show_backoff_when_still_running( type(mock_motionmount).can_authenticate = PropertyMock(return_value=1) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=MOCK_USER_INPUT.copy(), + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_INPUT.copy(), ) assert result["type"] is FlowResultType.FORM @@ -666,9 +738,15 @@ async def test_authentication_correct_pin( user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, ) assert result["type"] is FlowResultType.FORM diff --git a/tests/components/nightscout/test_config_flow.py b/tests/components/nightscout/test_config_flow.py index d139a66270c25b..0131b707f0e792 100644 --- a/tests/components/nightscout/test_config_flow.py +++ b/tests/components/nightscout/test_config_flow.py @@ -116,10 +116,16 @@ async def test_user_form_duplicate(hass: HomeAssistant) -> None: entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=CONFIG, + DOMAIN, context={"source": config_entries.SOURCE_USER} ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG + ) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/nmbs/test_config_flow.py b/tests/components/nmbs/test_config_flow.py index 2684326359a119..44e3c05c59492e 100644 --- a/tests/components/nmbs/test_config_flow.py +++ b/tests/components/nmbs/test_config_flow.py @@ -105,13 +105,20 @@ async def test_abort_if_exists( """Test aborting the flow if the entry already exists.""" mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], }, ) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -122,14 +129,21 @@ async def test_dont_abort_if_exists_when_vias_differs( """Test aborting the flow if the entry already exists.""" mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], CONF_EXCLUDE_VIAS: True, }, ) + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/nzbget/test_config_flow.py b/tests/components/nzbget/test_config_flow.py index fe903926a384a1..4943a2c8bf09bb 100644 --- a/tests/components/nzbget/test_config_flow.py +++ b/tests/components/nzbget/test_config_flow.py @@ -127,9 +127,8 @@ async def test_user_form_single_instance_allowed(hass: HomeAssistant) -> None: entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=USER_INPUT, + DOMAIN, context={"source": SOURCE_USER} ) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/osoenergy/test_config_flow.py b/tests/components/osoenergy/test_config_flow.py index 0d77781a538efc..3d78b818f9fdad 100644 --- a/tests/components/osoenergy/test_config_flow.py +++ b/tests/components/osoenergy/test_config_flow.py @@ -103,9 +103,15 @@ async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: return_value=TEST_USER_EMAIL, ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={ + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_API_KEY: SUBSCRIPTION_KEY, }, ) diff --git a/tests/components/owntracks/test_config_flow.py b/tests/components/owntracks/test_config_flow.py index 9fd22f3d556599..913824af86e3b6 100644 --- a/tests/components/owntracks/test_config_flow.py +++ b/tests/components/owntracks/test_config_flow.py @@ -131,7 +131,14 @@ async def test_unload(hass: HomeAssistant) -> None: "homeassistant.config_entries.ConfigEntries.async_forward_entry_setups" ) as mock_forward: result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data={} + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} ) assert len(mock_forward.mock_calls) == 1 @@ -168,7 +175,14 @@ async def test_with_cloud_sub(hass: HomeAssistant) -> None: ), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data={} + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -197,7 +211,14 @@ async def test_with_cloud_sub_not_connected(hass: HomeAssistant) -> None: ), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data={} + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/panasonic_viera/test_config_flow.py b/tests/components/panasonic_viera/test_config_flow.py index ef9e2968b979ea..58e0e1ee3edfd1 100644 --- a/tests/components/panasonic_viera/test_config_flow.py +++ b/tests/components/panasonic_viera/test_config_flow.py @@ -319,9 +319,15 @@ async def test_flow_non_encrypted_already_configured_abort(hass: HomeAssistant) ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={**MOCK_BASIC_DATA}, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={**MOCK_BASIC_DATA}, ) assert result["type"] is FlowResultType.ABORT @@ -338,9 +344,15 @@ async def test_flow_encrypted_already_configured_abort(hass: HomeAssistant) -> N ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={**MOCK_BASIC_DATA}, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={**MOCK_BASIC_DATA}, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/peblar/conftest.py b/tests/components/peblar/conftest.py index e8fe5fad7f446d..0fc3dbe48b5b5e 100644 --- a/tests/components/peblar/conftest.py +++ b/tests/components/peblar/conftest.py @@ -1,9 +1,10 @@ """Fixtures for the Peblar integration tests.""" +import asyncio from collections.abc import Generator from contextlib import nullcontext import json -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from peblar import ( PeblarEVInterface, @@ -81,6 +82,15 @@ def mock_peblar(request: pytest.FixtureRequest) -> Generator[MagicMock]: system_information ) + # The event stream parks here until the entry unloads, the way a + # real one waits on the charger rather than returning. + async def _listen_until_cancelled() -> None: + await asyncio.Event().wait() + + websocket = AsyncMock() + websocket.listen.side_effect = _listen_until_cancelled + peblar.websocket.return_value = websocket + api = peblar.rest_api.return_value api.ev_interface.return_value = PeblarEVInterface.from_json( load_fixture("ev_interface.json", DOMAIN) diff --git a/tests/components/peblar/snapshots/test_update.ambr b/tests/components/peblar/snapshots/test_update.ambr index 4ab471edbf7819..e24de6cdbbca0e 100644 --- a/tests/components/peblar/snapshots/test_update.ambr +++ b/tests/components/peblar/snapshots/test_update.ambr @@ -30,7 +30,7 @@ 'platform': 'peblar', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': 0, + 'supported_features': , 'translation_key': 'customization', 'unique_id': '23-45-A4O-MOF_customization', 'unit_of_measurement': None, @@ -49,7 +49,7 @@ : None, : None, : None, - : , + : , : None, : None, }), @@ -92,7 +92,7 @@ 'platform': 'peblar', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': 0, + 'supported_features': , 'translation_key': None, 'unique_id': '23-45-A4O-MOF_firmware', 'unit_of_measurement': None, @@ -112,7 +112,7 @@ : None, : None, : None, - : , + : , : None, : None, }), diff --git a/tests/components/peblar/test_update.py b/tests/components/peblar/test_update.py index 97d6a5937d0ffc..3f620e9cc04b21 100644 --- a/tests/components/peblar/test_update.py +++ b/tests/components/peblar/test_update.py @@ -1,14 +1,42 @@ """Tests for the Peblar update platform.""" +import asyncio +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from peblar import PackageType, PeblarConnectionError, PeblarVersions import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.peblar.const import DOMAIN -from homeassistant.const import Platform +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def _async_offer_both_updates( + hass: HomeAssistant, mock_peblar: MagicMock +) -> None: + """Put the charger on older packages, so both updates are on offer.""" + mock_peblar.current_versions.return_value = PeblarVersions.from_dict( + {"Customization": "Peblar-1.8", "Firmware": "1.6.1+1+WL-1"} + ) + mock_peblar.available_versions.return_value = PeblarVersions.from_dict( + {"Customization": "Peblar-1.9", "Firmware": "1.6.2+1+WL-1"} + ) + await hass.config_entries.async_reload( + hass.config_entries.async_entries(DOMAIN)[0].entry_id + ) + await hass.async_block_till_done() @pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True) @@ -33,3 +61,485 @@ async def test_entities( ) for entity_entry in entity_entries: assert entity_entry.device_id == device_entry.id + + +@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_install_firmware( + hass: HomeAssistant, + mock_peblar: MagicMock, +) -> None: + """Test installing the firmware asks the charger for that package. + + Only the firmware is out of date in the fixtures, which is the case + where installing it straight away is fine. + """ + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.peblar_ev_charger_firmware"}, + blocking=True, + ) + + mock_peblar.update.assert_called_once_with(package_type=PackageType.FIRMWARE) + + +@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_install_customization( + hass: HomeAssistant, + mock_peblar: MagicMock, +) -> None: + """Test installing the customization asks the charger for that package.""" + await _async_offer_both_updates(hass, mock_peblar) + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.peblar_ev_charger_customization"}, + blocking=True, + ) + + mock_peblar.update.assert_called_once_with(package_type=PackageType.CUSTOMIZATION) + + +@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_install_firmware_refuses_while_customization_is_pending( + hass: HomeAssistant, + mock_peblar: MagicMock, +) -> None: + """Test the charger is not put through a sequence it never sees. + + Peblar's own web interface installs the customization package first and + waits for the charger to come back before it touches the firmware. + """ + await _async_offer_both_updates(hass, mock_peblar) + + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.peblar_ev_charger_firmware"}, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "customization_update_first" + mock_peblar.update.assert_not_called() + + +@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_install_firmware_asks_the_charger_for_fresh_versions( + hass: HomeAssistant, + mock_peblar: MagicMock, +) -> None: + """Test a customization published since the last poll still blocks firmware. + + Versions are polled once every two hours, and the charger answers from + its own cache unless told not to, so the refusal would be decided on an + answer that predates the very package it is meant to catch. + """ + mock_peblar.current_versions.return_value = PeblarVersions.from_dict( + {"Customization": "Peblar-1.9", "Firmware": "1.6.1+1+WL-1"} + ) + mock_peblar.available_versions.return_value = PeblarVersions.from_dict( + {"Customization": "Peblar-1.9", "Firmware": "1.6.2+1+WL-1"} + ) + await hass.config_entries.async_reload( + hass.config_entries.async_entries(DOMAIN)[0].entry_id + ) + await hass.async_block_till_done() + + # Peblar publishes a customization package right after that poll. + mock_peblar.available_versions.return_value = PeblarVersions.from_dict( + {"Customization": "Peblar-2.0", "Firmware": "1.6.2+1+WL-1"} + ) + + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.peblar_ev_charger_firmware"}, + blocking=True, + ) + + assert excinfo.value.translation_key == "customization_update_first" + mock_peblar.available_versions.assert_called_with(use_cache=False) + mock_peblar.update.assert_not_called() + + +@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_install_firmware_after_the_customization_landed( + hass: HomeAssistant, + mock_peblar: MagicMock, +) -> None: + """Test the fresh answer counts the other way round too. + + The customization was installed on the charger since the last poll, so + there is nothing left to wait for and the firmware may go ahead. + """ + await _async_offer_both_updates(hass, mock_peblar) + + mock_peblar.current_versions.return_value = PeblarVersions.from_dict( + {"Customization": "Peblar-1.9", "Firmware": "1.6.1+1+WL-1"} + ) + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.peblar_ev_charger_firmware"}, + blocking=True, + ) + + mock_peblar.update.assert_called_once_with(package_type=PackageType.FIRMWARE) + + +async def _async_install(hass: HomeAssistant, package: str = "firmware") -> None: + """Install an update the way a user does.""" + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: f"update.peblar_ev_charger_{package}"}, + blocking=True, + ) + + +async def _async_forget_the_version_reads_so_far( + hass: HomeAssistant, mock_peblar: MagicMock +) -> None: + """Start counting version reads from here. + + Setting up and installing both read the versions themselves, so let + that settle first: what the tests below are after is the one extra read + that following the charger through its reboot asks for. + """ + await hass.async_block_till_done() + mock_peblar.current_versions.reset_mock() + + +async def _async_poll( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + after: timedelta = timedelta(seconds=15), +) -> None: + """Let the data coordinator run one poll, the given time from now.""" + freezer.tick(after) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + +@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_versions_are_reread_once_the_charger_is_back( + hass: HomeAssistant, + mock_peblar: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a charger that just updated stops offering the update it took. + + Installing returns long before the charger is done, and versions are + otherwise polled every two hours. Dropping off and coming back is what + the charger does in between, and the data poll sees both moments. + """ + meter = mock_peblar.rest_api.return_value.meter + + await _async_install(hass) + await _async_forget_the_version_reads_so_far(hass, mock_peblar) + + # Still reachable, so the charger has not started rebooting yet. + await _async_poll(hass, freezer) + mock_peblar.current_versions.assert_not_called() + + # It goes away to install and reboot. + meter.side_effect = PeblarConnectionError("Gone") + await _async_poll(hass, freezer) + await _async_poll(hass, freezer, after=timedelta(minutes=1)) + mock_peblar.current_versions.assert_not_called() + + # And comes back. + meter.side_effect = None + await _async_poll(hass, freezer) + mock_peblar.current_versions.assert_called_once() + + +@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_versions_are_not_reread_without_an_install( + hass: HomeAssistant, + mock_peblar: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a charger rebooting on its own does not trigger a version read.""" + meter = mock_peblar.rest_api.return_value.meter + + await _async_forget_the_version_reads_so_far(hass, mock_peblar) + + meter.side_effect = PeblarConnectionError("Gone") + await _async_poll(hass, freezer) + await _async_poll(hass, freezer, after=timedelta(minutes=1)) + meter.side_effect = None + await _async_poll(hass, freezer) + + mock_peblar.current_versions.assert_not_called() + + +@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_a_single_missed_poll_is_not_a_reboot( + hass: HomeAssistant, + mock_peblar: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a blip on the network does not end the wait. + + The charger is polled every ten seconds and may be downloading for + hours, so it gets asked a great many times. Treating a single missed + answer as a reboot would end the wait early, and the real reboot that + follows would go unnoticed. + """ + meter = mock_peblar.rest_api.return_value.meter + + await _async_install(hass) + await _async_forget_the_version_reads_so_far(hass, mock_peblar) + + # One missed answer, then the charger is there again. + meter.side_effect = PeblarConnectionError("Blip") + await _async_poll(hass, freezer) + meter.side_effect = None + await _async_poll(hass, freezer) + mock_peblar.current_versions.assert_not_called() + + # The actual reboot still gets noticed. + meter.side_effect = PeblarConnectionError("Gone") + await _async_poll(hass, freezer) + await _async_poll(hass, freezer, after=timedelta(minutes=1)) + meter.side_effect = None + await _async_poll(hass, freezer) + mock_peblar.current_versions.assert_called_once() + + +@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_a_slow_update_is_still_picked_up( + hass: HomeAssistant, + mock_peblar: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a charger that takes its time downloading is still followed. + + The charger downloads the package before it reboots, so it can stay + reachable for a long while after the install call returns. Peblar's own + web interface allows three hours for that, far longer than the ten + minutes it allows for the reboot itself. + """ + meter = mock_peblar.rest_api.return_value.meter + + await _async_install(hass) + await _async_forget_the_version_reads_so_far(hass, mock_peblar) + + # Half an hour of downloading, still reachable. + await _async_poll(hass, freezer, after=timedelta(minutes=30)) + + # Only now does it reboot, and come back. + meter.side_effect = PeblarConnectionError("Gone") + await _async_poll(hass, freezer) + await _async_poll(hass, freezer, after=timedelta(minutes=1)) + meter.side_effect = None + await _async_poll(hass, freezer) + + mock_peblar.current_versions.assert_called_once() + + +@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_waiting_stops_for_a_charger_that_never_returns( + hass: HomeAssistant, + mock_peblar: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the wait ends once the charger is overdue coming back. + + A charger that has gone down should be back within minutes. Waiting + beyond that means the update did not go the way it should have, and + whatever comes back later is not this update landing. + """ + meter = mock_peblar.rest_api.return_value.meter + + await _async_install(hass) + await _async_forget_the_version_reads_so_far(hass, mock_peblar) + + # The charger goes away, and stays away well past the ten minutes a + # reboot is allowed to take. + meter.side_effect = PeblarConnectionError("Gone") + await _async_poll(hass, freezer) + await _async_poll(hass, freezer, after=timedelta(minutes=1)) + await _async_poll(hass, freezer, after=timedelta(minutes=20)) + + # Whatever comes back now is not this update landing. + meter.side_effect = None + await _async_poll(hass, freezer) + + mock_peblar.current_versions.assert_not_called() + + +@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_blips_do_not_extend_the_wait( + hass: HomeAssistant, + mock_peblar: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the charger does not get longer than it was given. + + A blip puts the wait back to waiting for the reboot to start, but on + what is left of the original allowance. Handing out a fresh three hours + each time would let a flaky network keep this going forever. + """ + meter = mock_peblar.rest_api.return_value.meter + + await _async_install(hass) + + # Nearly out of time, then a blip. + await _async_poll(hass, freezer, after=timedelta(hours=2, minutes=59)) + meter.side_effect = PeblarConnectionError("Blip") + await _async_poll(hass, freezer) + meter.side_effect = None + await _async_poll(hass, freezer) + + # Past the three hours the charger was given from the start. + await _async_poll(hass, freezer, after=timedelta(minutes=5)) + + # So a reboot now is no longer this update landing, however long the + # charger stays away for. + await _async_forget_the_version_reads_so_far(hass, mock_peblar) + meter.side_effect = PeblarConnectionError("Gone") + await _async_poll(hass, freezer) + meter.side_effect = None + await _async_poll(hass, freezer, after=timedelta(minutes=1)) + + mock_peblar.current_versions.assert_not_called() + + +@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_the_install_runs_on_until_the_new_versions_are_in( + hass: HomeAssistant, + mock_peblar: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the button does not come back before the versions it acts on. + + Calling the install done while the versions are still the ones from + before it would put the button back next to the very package the + charger has just taken. + """ + entity_id = "update.peblar_ev_charger_firmware" + meter = mock_peblar.rest_api.return_value.meter + reading_versions = asyncio.Event() + let_the_read_finish = asyncio.Event() + + async def _read_slowly() -> PeblarVersions: + reading_versions.set() + await let_the_read_finish.wait() + return PeblarVersions.from_dict( + {"Customization": "Peblar-1.9", "Firmware": "1.6.2+1+WL-1"} + ) + + await _async_install(hass) + + # It goes away to install and reboot. + meter.side_effect = PeblarConnectionError("Gone") + await _async_poll(hass, freezer) + await _async_poll(hass, freezer, after=timedelta(minutes=1)) + + # And comes back, on a charger that is slow to answer for its versions. + mock_peblar.current_versions.side_effect = _read_slowly + meter.side_effect = None + freezer.tick(timedelta(seconds=15)) + async_fire_time_changed(hass) + await reading_versions.wait() + + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_IN_PROGRESS] is True + + let_the_read_finish.set() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_IN_PROGRESS] is False + + +@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_a_second_install_is_refused_while_one_runs( + hass: HomeAssistant, + mock_peblar: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the charger is not handed a second package mid update. + + The install call returns while the charger is still downloading, so + without saying so the button would be offered again straight away. + Reporting the install as in progress is what makes the update + component refuse a second one. + """ + entity_id = "update.peblar_ev_charger_firmware" + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_IN_PROGRESS] is True + + # Once the charger is back, it can be asked again. + meter = mock_peblar.rest_api.return_value.meter + meter.side_effect = PeblarConnectionError("Gone") + await _async_poll(hass, freezer) + await _async_poll(hass, freezer, after=timedelta(minutes=1)) + meter.side_effect = None + await _async_poll(hass, freezer) + + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_IN_PROGRESS] is False + + +@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_the_button_returns_for_a_charger_that_never_came_back( + hass: HomeAssistant, + mock_peblar: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a charger that goes missing does not block installs forever.""" + entity_id = "update.peblar_ev_charger_firmware" + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + meter = mock_peblar.rest_api.return_value.meter + meter.side_effect = PeblarConnectionError("Gone") + await _async_poll(hass, freezer) + await _async_poll(hass, freezer, after=timedelta(minutes=1)) + + # Well past the ten minutes a reboot is allowed to take. + freezer.tick(timedelta(minutes=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_IN_PROGRESS] is False diff --git a/tests/components/peblar/test_websocket.py b/tests/components/peblar/test_websocket.py new file mode 100644 index 00000000000000..f106dd53e7120a --- /dev/null +++ b/tests/components/peblar/test_websocket.py @@ -0,0 +1,133 @@ +"""Tests for the Peblar event stream.""" + +import asyncio +from unittest.mock import MagicMock, patch + +from peblar import PeblarConnectionError, PeblarSessionStatus, SessionState +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +pytestmark = [ + pytest.mark.parametrize("init_integration", [Platform.SENSOR], indirect=True), + pytest.mark.usefixtures("init_integration"), +] + + +async def test_the_stream_is_subscribed_to(mock_peblar: MagicMock) -> None: + """Test the charger's session is followed as soon as the entry loads.""" + websocket = mock_peblar.websocket.return_value + websocket.connect.assert_awaited_once() + websocket.subscribe_session_status.assert_awaited_once() + + +async def test_a_session_change_pulls_the_poll_forward( + hass: HomeAssistant, + mock_peblar: MagicMock, +) -> None: + """Test an event asks the poll to catch up rather than waiting it out.""" + meter = mock_peblar.rest_api.return_value.meter + meter.reset_mock() + + websocket = mock_peblar.websocket.return_value + handle_session_status = websocket.subscribe_session_status.call_args.args[0] + handle_session_status( + PeblarSessionStatus(state=SessionState.CHARGING, meter_data=None) + ) + await hass.async_block_till_done() + + meter.assert_awaited() + + +async def test_the_wait_backs_off_and_settles_once_the_charger_answers( + hass: HomeAssistant, + mock_peblar: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test how long the stream waits between attempts. + + A charger that cannot be reached is given more room each time. Once it + answers, that is settled: a drop hours later starts over from the + shortest wait rather than the longest one reached at startup. + """ + websocket = mock_peblar.websocket.return_value + websocket.connect.side_effect = [ + PeblarConnectionError("Gone"), + PeblarConnectionError("Still gone"), + None, + None, + ] + + hang_ups = 0 + + async def _hang_up_once() -> None: + nonlocal hang_ups + hang_ups += 1 + if hang_ups == 1: + return + await asyncio.Event().wait() + + websocket.listen.side_effect = _hang_up_once + + waits: list[float] = [] + + async def _record(delay: float) -> None: + waits.append(delay) + + with patch("homeassistant.components.peblar.websocket.asyncio.sleep", _record): + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Five seconds, then ten while the charger stays away. It answers on + # the third try and hangs up, and the wait is back to five. + assert waits[:3] == [5, 10, 5] + + +async def test_a_subscription_that_never_lands_keeps_backing_off( + hass: HomeAssistant, + mock_peblar: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test taking the socket is not the same as having a stream. + + A charger that accepts the connection but never completes the + subscription would otherwise be retried every five seconds forever. + """ + websocket = mock_peblar.websocket.return_value + subscriptions = 0 + + async def _refuse_twice(_callback: object) -> None: + nonlocal subscriptions + subscriptions += 1 + if subscriptions <= 2: + raise PeblarConnectionError("Not listening") + + websocket.subscribe_session_status.side_effect = _refuse_twice + + waits: list[float] = [] + + async def _record(delay: float) -> None: + waits.append(delay) + + with patch("homeassistant.components.peblar.websocket.asyncio.sleep", _record): + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # The socket opened every time, so a reset on that alone would have + # left both waits at five seconds. + assert waits[:2] == [5, 10] + + +async def test_the_stream_is_closed_when_the_entry_unloads( + hass: HomeAssistant, + mock_peblar: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the charger is let go of when the entry goes away.""" + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_peblar.websocket.return_value.disconnect.assert_awaited() diff --git a/tests/components/pi_hole/test_config_flow.py b/tests/components/pi_hole/test_config_flow.py index 6c856dcdc2175c..b3ffc35e8ef6da 100644 --- a/tests/components/pi_hole/test_config_flow.py +++ b/tests/components/pi_hole/test_config_flow.py @@ -59,9 +59,15 @@ async def test_flow_user_with_api_key_v6(hass: HomeAssistant) -> None: # duplicated server result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONFIG_FLOW_USER, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG_FLOW_USER, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -109,9 +115,15 @@ async def test_flow_user_with_api_key_v5(hass: HomeAssistant) -> None: # duplicated server result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONFIG_FLOW_USER, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG_FLOW_USER, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -122,7 +134,15 @@ async def test_flow_user_invalid(hass: HomeAssistant) -> None: mocked_hole = _create_mocked_hole(raise_exception=True) with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG_FLOW_USER + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG_FLOW_USER, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -136,7 +156,15 @@ async def test_flow_user_invalid_v6(hass: HomeAssistant) -> None: ) with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG_FLOW_USER + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG_FLOW_USER, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -176,7 +204,15 @@ async def test_flow_user_invalid_host(hass: HomeAssistant) -> None: mocked_hole = _create_mocked_hole(api_version=6, wrong_host=True) with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG_FLOW_USER + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG_FLOW_USER, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -188,7 +224,15 @@ async def test_flow_error_response(hass: HomeAssistant) -> None: mocked_hole = _create_mocked_hole(api_version=5, ftl_error=True, has_data=False) with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG_FLOW_USER + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG_FLOW_USER, ) assert mocked_hole.instances[-1].data == FTL_ERROR assert result["type"] is FlowResultType.FORM diff --git a/tests/components/plaato/test_config_flow.py b/tests/components/plaato/test_config_flow.py index f9edc7d355e0d7..589790cd9fd440 100644 --- a/tests/components/plaato/test_config_flow.py +++ b/tests/components/plaato/test_config_flow.py @@ -54,9 +54,15 @@ async def test_show_config_form(hass: HomeAssistant) -> None: async def test_show_config_form_device_type_airlock(hass: HomeAssistant) -> None: """Test show configuration form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={ + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_DEVICE_TYPE: PlaatoDeviceType.Airlock, CONF_DEVICE_NAME: "device_name", }, @@ -71,9 +77,18 @@ async def test_show_config_form_device_type_airlock(hass: HomeAssistant) -> None async def test_show_config_form_device_type_keg(hass: HomeAssistant) -> None: """Test show configuration form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_DEVICE_TYPE: PlaatoDeviceType.Keg, CONF_DEVICE_NAME: "device_name"}, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_DEVICE_TYPE: PlaatoDeviceType.Keg, + CONF_DEVICE_NAME: "device_name", + }, ) assert result["type"] is FlowResultType.FORM diff --git a/tests/components/prana/test_fan.py b/tests/components/prana/test_fan.py index bf51222420b3ff..17907eea0cbcf0 100644 --- a/tests/components/prana/test_fan.py +++ b/tests/components/prana/test_fan.py @@ -1,6 +1,5 @@ """Integration-style tests for Prana fans.""" -import math from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -153,7 +152,7 @@ async def test_fans_set_percentage( blocking=True, ) expected_speed = ( - math.ceil(percentage_to_ranged_value((1, fan_mock_state.max_speed), 50)) + round(percentage_to_ranged_value((1, fan_mock_state.max_speed), 50)) * PRANA_SPEED_MULTIPLIER ) mock_prana_api.set_speed.assert_called_once_with( @@ -171,6 +170,58 @@ async def test_fans_set_percentage( mock_prana_api.set_speed_is_on.assert_called_with(False, expected_api_key) +@pytest.mark.parametrize( + ("percentage", "expected_step"), + [ + (1, 1), # any non-zero percentage turns the fan on at least at step 1 + (16, 1), + (17, 1), # UI-displayed value for step 1 of 6 (16.67% rounded up) + (33, 2), + (50, 3), + (66, 4), + (67, 4), # UI-displayed value for step 4 of 6 (66.67% rounded up) + (83, 5), + (100, 6), + ], +) +async def test_fans_percentage_maps_to_nearest_step( + hass: HomeAssistant, + mock_prana_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + percentage: int, + expected_step: int, +) -> None: + """Test that displayed percentages map back to the same speed step. + + A 6-step fan reports step 4 as 66.67%, which the UI displays as 67%. + Sending that displayed value back must select step 4 again, not step 5. + """ + mock_prana_api.get_state.return_value.supply.max_speed = 6 + target, fan_mock_state = await _async_setup_fan_entity( + hass, + mock_prana_api, + mock_config_entry, + entity_registry, + "supply", + False, + ) + + fan_mock_state.is_on = True + await hass.async_block_till_done() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: target, ATTR_PERCENTAGE: percentage}, + blocking=True, + ) + mock_prana_api.set_speed.assert_called_once_with( + expected_step * PRANA_SPEED_MULTIPLIER, + "supply", + ) + + @pytest.mark.parametrize( ("type_key", "is_bound_mode", "expected_api_key"), FAN_TEST_CASES, diff --git a/tests/components/qnap_qsw/test_config_flow.py b/tests/components/qnap_qsw/test_config_flow.py index 8048c4bc16afc1..48bbfe574aee81 100644 --- a/tests/components/qnap_qsw/test_config_flow.py +++ b/tests/components/qnap_qsw/test_config_flow.py @@ -100,7 +100,15 @@ async def test_form_duplicated_id(hass: HomeAssistant) -> None: return_value=system_board, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG, ) assert result["type"] is FlowResultType.ABORT @@ -118,7 +126,15 @@ async def test_form_unique_id_error(hass: HomeAssistant) -> None: return_value=system_board, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG, ) assert result["type"] is FlowResultType.ABORT @@ -133,7 +149,15 @@ async def test_connection_error(hass: HomeAssistant) -> None: side_effect=QswError, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG, ) assert result["errors"] == {CONF_URL: "cannot_connect"} @@ -147,7 +171,15 @@ async def test_login_error(hass: HomeAssistant) -> None: side_effect=LoginError, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG, ) assert result["errors"] == {CONF_PASSWORD: "invalid_auth"} diff --git a/tests/components/sensorpush_cloud/test_config_flow.py b/tests/components/sensorpush_cloud/test_config_flow.py index 27fac7fbba96e6..4d316a6c9f8502 100644 --- a/tests/components/sensorpush_cloud/test_config_flow.py +++ b/tests/components/sensorpush_cloud/test_config_flow.py @@ -47,7 +47,15 @@ async def test_user_already_configured( """Test we fail on a duplicate entry in the user flow.""" mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/shopping_list/test_config_flow.py b/tests/components/shopping_list/test_config_flow.py index 4f6f5270c08f37..85afc41e7c1fac 100644 --- a/tests/components/shopping_list/test_config_flow.py +++ b/tests/components/shopping_list/test_config_flow.py @@ -30,7 +30,15 @@ async def test_user_confirm(hass: HomeAssistant) -> None: """Test we can finish a config flow.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={} + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, ) assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/silla_prism/test_config_flow.py b/tests/components/silla_prism/test_config_flow.py index 401ef0daac96d7..8d3dec7f6b6003 100644 --- a/tests/components/silla_prism/test_config_flow.py +++ b/tests/components/silla_prism/test_config_flow.py @@ -70,7 +70,7 @@ async def test_user_flow(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> No async def test_user_flow_no_device( hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: - """Test the user flow errors when no traffic is seen.""" + """Test the user flow errors when no traffic is seen, then recovers.""" with patch(_PROBE_PATH, return_value=False): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -80,19 +80,27 @@ async def test_user_flow_no_device( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "no_device"} + with patch(_PROBE_PATH, return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_BASE_TOPIC: BASE_TOPIC} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_BASE_TOPIC: BASE_TOPIC} + async def test_user_flow_mqtt_unavailable( hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: - """Test the user flow errors when MQTT is not available.""" + """Test the user flow aborts when MQTT is not available.""" with patch(_MQTT_CLIENT_PATH, return_value=False): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data={CONF_BASE_TOPIC: BASE_TOPIC}, ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "mqtt_unavailable"} + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "mqtt_unavailable" @pytest.mark.parametrize( @@ -103,7 +111,7 @@ async def test_user_flow_mqtt_unavailable( async def test_user_flow_invalid_base_topic( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, base_topic: str ) -> None: - """Test the user flow rejects base topics that are not valid MQTT topics.""" + """Test the user flow rejects invalid base topics, then recovers.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -112,6 +120,14 @@ async def test_user_flow_invalid_base_topic( assert result["type"] is FlowResultType.FORM assert result["errors"] == {CONF_BASE_TOPIC: "invalid_base_topic"} + with patch(_PROBE_PATH, return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_BASE_TOPIC: BASE_TOPIC} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_BASE_TOPIC: BASE_TOPIC} + async def test_user_flow_already_configured( hass: HomeAssistant, diff --git a/tests/components/sky_remote/test_config_flow.py b/tests/components/sky_remote/test_config_flow.py index 14b9ad1fe2d632..764baf95518570 100644 --- a/tests/components/sky_remote/test_config_flow.py +++ b/tests/components/sky_remote/test_config_flow.py @@ -43,9 +43,15 @@ async def test_device_exists_abort( mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: mock_config_entry.data[CONF_HOST]}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: mock_config_entry.data[CONF_HOST]}, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/smart_meter_texas/test_config_flow.py b/tests/components/smart_meter_texas/test_config_flow.py index a98597686d5cd1..48ffb98502b100 100644 --- a/tests/components/smart_meter_texas/test_config_flow.py +++ b/tests/components/smart_meter_texas/test_config_flow.py @@ -119,9 +119,15 @@ async def test_form_duplicate_account(hass: HomeAssistant) -> None: return_value=True, ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={"username": "user123", "password": "password123"}, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"username": "user123", "password": "password123"}, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index fc8330524d3f4e..656e751c449710 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -349,8 +349,8 @@ , , ]), - : 35, - : 7, + : 30, + : 16, : list([ 'none', 'wind_free', @@ -412,8 +412,8 @@ , , ]), - : 35, - : 7, + : 30, + : 16, : 'wind_free', : list([ 'none', @@ -454,8 +454,8 @@ , , ]), - : 35, - : 7, + : 30, + : 16, : list([ 'none', 'sleep', @@ -529,8 +529,8 @@ , , ]), - : 35, - : 7, + : 30, + : 16, : 'none', : list([ 'none', @@ -709,8 +709,8 @@ , , ]), - : 35, - : 7, + : 30, + : 16, }), 'config_entry_id': , 'config_subentry_id': , @@ -762,8 +762,8 @@ , , ]), - : 35, - : 7, + : 30, + : 16, : , : 18, }), diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index 77b48acc339ba6..6ab9a812898767 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -824,9 +824,10 @@ async def test_ac_setpoint_range_update( """Test the setpoint range is used when the device reports one.""" await setup_integration(hass, mock_config_entry) + # Without a setpoint range the custom setpoint bounds are used state = hass.states.get("climate.theater_ac_office_granit") - assert state.attributes[ATTR_MIN_TEMP] == DEFAULT_MIN_TEMP - assert state.attributes[ATTR_MAX_TEMP] == DEFAULT_MAX_TEMP + assert state.attributes[ATTR_MIN_TEMP] == 16 + assert state.attributes[ATTR_MAX_TEMP] == 30 assert ATTR_TARGET_TEMP_STEP not in state.attributes await trigger_update( @@ -883,6 +884,69 @@ async def test_ac_setpoint_range_converted_to_device_unit( assert state.attributes[ATTR_TARGET_TEMP_STEP] == 1.8 +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000003"]) +async def test_ac_custom_setpoint_bounds( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the custom setpoint bounds are used when there is no setpoint range.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.clim_salon") + assert state.attributes[ATTR_MIN_TEMP] == 16 + assert state.attributes[ATTR_MAX_TEMP] == 30 + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000003"]) +@pytest.mark.parametrize("value", [None, -1000]) +async def test_ac_custom_setpoint_bounds_unavailable( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + value: int | None, +) -> None: + """Test we fall back to the defaults when the custom bounds are unavailable.""" + set_attribute_value( + devices, + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, + Attribute.MINIMUM_SETPOINT, + value, + ) + set_attribute_value( + devices, + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, + Attribute.MAXIMUM_SETPOINT, + value, + ) + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.clim_salon") + assert state.attributes[ATTR_MIN_TEMP] == DEFAULT_MIN_TEMP + assert state.attributes[ATTR_MAX_TEMP] == DEFAULT_MAX_TEMP + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_01001"]) +async def test_ac_setpoint_range_takes_precedence( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the setpoint range wins over the custom setpoint bounds.""" + set_attribute_value( + devices, + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, + Attribute.MINIMUM_SETPOINT, + 5, + ) + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.theater_aire_dormitorio_principal") + assert state.attributes[ATTR_MIN_TEMP] == 16 + + @pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) async def test_thermostat_set_fan_mode( hass: HomeAssistant, diff --git a/tests/components/smtp/conftest.py b/tests/components/smtp/conftest.py index 0a0b00af2542ed..247b3abfff6072 100644 --- a/tests/components/smtp/conftest.py +++ b/tests/components/smtp/conftest.py @@ -49,12 +49,23 @@ def mock_setup_entry() -> Generator[AsyncMock]: def mock_smtp() -> Generator[MagicMock]: """Mock smtplib.SMTP.""" + with patch( + "homeassistant.components.smtp.helpers.smtplib.SMTP", autospec=True + ) as mock_client: + client = mock_client.return_value + client.cls = mock_client + yield client + + +@pytest.fixture(name="aiosmtplib") +def mock_aiosmtplib() -> Generator[AsyncMock]: + """Mock aiosmtplib.""" + with ( patch( - "homeassistant.components.smtp.config_flow.SMTP_SSL", autospec=True + "homeassistant.components.smtp.config_flow.SMTP", autospec=True ) as mock_client, - patch("homeassistant.components.smtp.helpers.smtplib.SMTP", new=mock_client), - patch("homeassistant.components.smtp.config_flow.SMTP", new=mock_client), + patch("homeassistant.components.smtp.SMTP", new=mock_client), ): client = mock_client.return_value client.cls = mock_client @@ -83,6 +94,22 @@ def mock_randrange() -> Generator[None]: yield +@pytest.fixture(name="client_context") +def mock_client_context() -> Generator[None]: + """Mock client_context.""" + + with ( + patch( + "homeassistant.components.smtp.config_flow.client_context" + ) as mock_client, + patch( + "homeassistant.components.smtp.client_context", + new=mock_client, + ), + ): + yield mock_client + + @pytest.fixture(name="config_entry") def mock_config_entry() -> MockConfigEntry: """Mock smtp configuration entry.""" diff --git a/tests/components/smtp/test_config_flow.py b/tests/components/smtp/test_config_flow.py index 2708daee3e9022..211c23b36bde1b 100644 --- a/tests/components/smtp/test_config_flow.py +++ b/tests/components/smtp/test_config_flow.py @@ -1,14 +1,11 @@ """Test the SMTP config flow.""" -from smtplib import SMTPAuthenticationError, SMTPServerDisconnected -from socket import gaierror -from ssl import SSLCertVerificationError from unittest.mock import AsyncMock, MagicMock +from aiosmtplib import SMTPAuthenticationError, SMTPException, SMTPTimeoutError import pytest from homeassistant.components.smtp.const import ( - CONF_ENCRYPTION, CONF_SENDER_NAME, DOMAIN, SECTION_OPTIONS, @@ -38,9 +35,12 @@ from tests.common import MockConfigEntry -@pytest.mark.parametrize("encryption", ["tls", "starttls"]) +@pytest.mark.usefixtures("smtp") async def test_form( - hass: HomeAssistant, mock_setup_entry: AsyncMock, encryption: str, smtp: MagicMock + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + aiosmtplib: AsyncMock, + client_context: MagicMock, ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -53,7 +53,6 @@ async def test_form( result["flow_id"], { **USER_INPUT, - CONF_ENCRYPTION: encryption, SECTION_OPTIONS: {CONF_TIMEOUT: 60}, }, ) @@ -61,10 +60,7 @@ async def test_form( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Home Assistant" - assert result["data"] == { - **USER_INPUT, - CONF_ENCRYPTION: encryption, - } + assert result["data"] == USER_INPUT assert result["options"] == {CONF_TIMEOUT: 60} assert len(mock_setup_entry.mock_calls) == 1 @@ -81,11 +77,19 @@ async def test_form( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Recipient" assert result["unique_id"] == "recipient@example.com" - assert smtp.cls.call_args[0] == ("mail.example.com", 587) - assert smtp.cls.call_args[1]["timeout"] == 60 + aiosmtplib.cls.assert_called_once_with( + hostname="mail.example.com", + port=587, + username="test-username", + password="test-password", + timeout=60, + use_tls=False, + start_tls=True, + tls_context=client_context(), + ) -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_form_already_configured( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -117,18 +121,16 @@ async def test_form_already_configured( ("exception", "text_error"), [ (SMTPAuthenticationError(0, ""), "invalid_auth"), - (ConnectionRefusedError, "cannot_connect"), - (TimeoutError, "timeout_connect"), - (SMTPServerDisconnected, "cannot_connect"), - (gaierror, "cannot_connect"), - (SSLCertVerificationError, "invalid_cert"), + (SMTPException(""), "cannot_connect"), + (SMTPTimeoutError(""), "timeout_connect"), (ValueError, "unknown"), ], ) +@pytest.mark.usefixtures("smtp") async def test_form_errors( hass: HomeAssistant, mock_setup_entry: AsyncMock, - smtp: MagicMock, + aiosmtplib: MagicMock, exception: Exception, text_error: str, ) -> None: @@ -137,7 +139,7 @@ async def test_form_errors( DOMAIN, context={"source": SOURCE_USER} ) - smtp.login.side_effect = exception + aiosmtplib.cls.side_effect = exception result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -150,7 +152,7 @@ async def test_form_errors( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": text_error} - smtp.login.side_effect = None + aiosmtplib.cls.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -168,7 +170,7 @@ async def test_form_errors( assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_form_recipient_already_configured( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -198,7 +200,7 @@ async def test_form_recipient_already_configured( assert result["reason"] == "already_configured" -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_options_flow( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -229,8 +231,12 @@ async def test_options_flow( } +@pytest.mark.usefixtures("smtp") async def test_form_reconfigure( - hass: HomeAssistant, config_entry: MockConfigEntry, smtp: MagicMock + hass: HomeAssistant, + config_entry: MockConfigEntry, + aiosmtplib: AsyncMock, + client_context: MagicMock, ) -> None: """Test reconfigure flow.""" @@ -263,10 +269,19 @@ async def test_form_reconfigure( } assert len(hass.config_entries.async_entries()) == 1 - smtp.cls.assert_called_with("mail.example.com", 587, timeout=1312) + aiosmtplib.cls.assert_called_once_with( + hostname="mail.example.com", + port=587, + username="new-username", + password="new-password", + timeout=1312, + use_tls=False, + start_tls=True, + tls_context=client_context(), + ) -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_form_reconfigure_already_configured( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: @@ -308,11 +323,8 @@ async def test_form_reconfigure_already_configured( ("exception", "text_error"), [ (SMTPAuthenticationError(0, ""), "invalid_auth"), - (ConnectionRefusedError, "cannot_connect"), - (SMTPServerDisconnected, "cannot_connect"), - (TimeoutError, "timeout_connect"), - (gaierror, "cannot_connect"), - (SSLCertVerificationError, "invalid_cert"), + (SMTPException(""), "cannot_connect"), + (SMTPTimeoutError(""), "timeout_connect"), (ValueError, "unknown"), ], ) @@ -320,13 +332,13 @@ async def test_form_reconfigure_already_configured( async def test_form_reconfigure_errors( hass: HomeAssistant, config_entry: MockConfigEntry, - smtp: MagicMock, + aiosmtplib: AsyncMock, exception: Exception, text_error: str, ) -> None: """Test reconfigure flow connection errors.""" - smtp.login.side_effect = exception + aiosmtplib.cls.side_effect = exception config_entry.add_to_hass(hass) @@ -348,7 +360,7 @@ async def test_form_reconfigure_errors( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": text_error} - smtp.login.side_effect = None + aiosmtplib.cls.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -372,8 +384,12 @@ async def test_form_reconfigure_errors( assert len(hass.config_entries.async_entries()) == 1 +@pytest.mark.usefixtures("smtp") async def test_form_reauth( - hass: HomeAssistant, config_entry: MockConfigEntry, smtp: MagicMock + hass: HomeAssistant, + config_entry: MockConfigEntry, + aiosmtplib: AsyncMock, + client_context: MagicMock, ) -> None: """Test reauth flow.""" @@ -403,16 +419,24 @@ async def test_form_reauth( } assert len(hass.config_entries.async_entries()) == 1 - smtp.cls.assert_called_with("mail.example.com", 587, timeout=1312) + aiosmtplib.cls.assert_called_once_with( + hostname="mail.example.com", + port=587, + username="new-username", + password="new-password", + timeout=1312, + use_tls=False, + start_tls=True, + tls_context=client_context(), + ) @pytest.mark.parametrize( ("exception", "text_error"), [ (SMTPAuthenticationError(0, ""), "invalid_auth"), - (ConnectionRefusedError, "cannot_connect"), - (gaierror, "cannot_connect"), - (SSLCertVerificationError, "invalid_cert"), + (SMTPException(""), "cannot_connect"), + (SMTPTimeoutError(""), "timeout_connect"), (ValueError, "unknown"), ], ) @@ -420,13 +444,13 @@ async def test_form_reauth( async def test_form_reauth_errors( hass: HomeAssistant, config_entry: MockConfigEntry, - smtp: MagicMock, + aiosmtplib: AsyncMock, exception: Exception, text_error: str, ) -> None: """Test reauth flow connection errors.""" - smtp.login.side_effect = exception + aiosmtplib.cls.side_effect = exception config_entry.add_to_hass(hass) @@ -446,7 +470,7 @@ async def test_form_reauth_errors( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": text_error} - smtp.login.side_effect = None + aiosmtplib.cls.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -467,7 +491,7 @@ async def test_form_reauth_errors( assert len(hass.config_entries.async_entries()) == 1 -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_form_subentry_reconfigure( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -508,7 +532,7 @@ async def test_form_subentry_reconfigure( assert entity.unique_id == "123456789_changed@example.com" -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_form_subentry_reconfigure_already_configured( hass: HomeAssistant, ) -> None: @@ -555,7 +579,7 @@ async def test_form_subentry_reconfigure_already_configured( assert result["reason"] == "already_configured" -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_form_subentry_reconfigure_updates_title( hass: HomeAssistant, ) -> None: diff --git a/tests/components/smtp/test_init.py b/tests/components/smtp/test_init.py index 065bef21636a89..a7ac325f71e187 100644 --- a/tests/components/smtp/test_init.py +++ b/tests/components/smtp/test_init.py @@ -1,9 +1,8 @@ """Tests for the SMTP integration.""" -from smtplib import SMTPAuthenticationError -from socket import gaierror -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock +from aiosmtplib import SMTPAuthenticationError, SMTPException import pytest from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN @@ -33,7 +32,7 @@ from tests.common import MockConfigEntry -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_entry_setup_unload( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: @@ -57,21 +56,21 @@ async def test_entry_setup_unload( @pytest.mark.parametrize( ("exception", "state"), [ - (ConnectionRefusedError, ConfigEntryState.SETUP_RETRY), - (gaierror, ConfigEntryState.SETUP_RETRY), + (SMTPException(""), ConfigEntryState.SETUP_RETRY), (SMTPAuthenticationError(0, ""), ConfigEntryState.SETUP_ERROR), ], ) +@pytest.mark.usefixtures("smtp") async def test_config_entry_not_ready( hass: HomeAssistant, config_entry: MockConfigEntry, - smtp: MagicMock, + aiosmtplib: AsyncMock, exception: Exception, state: ConfigEntryState, ) -> None: """Test config entry not ready.""" - smtp.login.side_effect = exception + aiosmtplib.__aenter__.side_effect = exception config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) @@ -80,7 +79,7 @@ async def test_config_entry_not_ready( assert config_entry.state is state -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_import( hass: HomeAssistant, mock_setup_entry: AsyncMock, @@ -203,14 +202,15 @@ async def test_import_already_configured( ) +@pytest.mark.usefixtures("smtp") async def test_import_errors( hass: HomeAssistant, mock_setup_entry: AsyncMock, issue_registry: ir.IssueRegistry, - smtp: MagicMock, + aiosmtplib: AsyncMock, ) -> None: """Test yaml triggers import flow, aborts with errors, and creates error issue.""" - smtp.login.side_effect = ValueError + aiosmtplib.__aenter__.side_effect = ValueError await async_setup_component( hass, diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index e91a41ac7ddc84..eae90eceecbf73 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -2,16 +2,10 @@ from pathlib import Path import re -from smtplib import ( - SMTPAuthenticationError, - SMTPException, - SMTPHeloError, - SMTPSenderRefused, - SMTPServerDisconnected, -) -from socket import gaierror -from unittest.mock import MagicMock, patch +from smtplib import SMTPException, SMTPServerDisconnected +from unittest.mock import AsyncMock, MagicMock, patch +import aiosmtplib import pytest from syrupy.assertion import SnapshotAssertion @@ -211,7 +205,7 @@ def test_send_target_message(target, hass: HomeAssistant, message) -> None: assert recipient == expected_recipient -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_notify_platform( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -229,12 +223,12 @@ async def test_notify_platform( await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) -@pytest.mark.usefixtures("make_msgid") +@pytest.mark.usefixtures("make_msgid", "smtp") @pytest.mark.freeze_time("2026-05-03T03:09:37+00:00") async def test_notify_send_message( hass: HomeAssistant, config_entry: MockConfigEntry, - smtp: MagicMock, + aiosmtplib: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test sending an email message via notify.send_message action.""" @@ -263,30 +257,26 @@ async def test_notify_send_message( assert state assert state.state == "2026-05-03T03:09:37+00:00" - assert smtp.sendmail.call_args[0][0] == "email@example.com" - assert smtp.sendmail.call_args[0][1] == "recipient@example.com" - assert smtp.sendmail.call_args[0][2] == snapshot + msg = aiosmtplib.__aenter__.return_value.send_message.call_args[0][0] + assert msg.as_string() == snapshot @pytest.mark.parametrize( - ("call_method", "exception", "translation_key"), + ("exception", "translation_key", "call_count"), [ - ("login", SMTPAuthenticationError(0, ""), "authentication_error"), - ("login", gaierror, "send_mail_connection_error"), - ("login", ConnectionRefusedError, "send_mail_connection_error"), - ("login", SMTPHeloError(0, ""), "send_mail_connection_error"), - ("sendmail", SMTPServerDisconnected, "send_mail_connection_error"), - ("sendmail", SMTPSenderRefused(0, b"", ""), "send_mail_connection_error"), + (aiosmtplib.SMTPAuthenticationError(0, ""), "authentication_error", 1), + (aiosmtplib.SMTPException(""), "send_mail_connection_error", 2), ], ) +@pytest.mark.usefixtures("make_msgid", "smtp") @pytest.mark.freeze_time("2026-05-03T03:09:37+00:00") async def test_notify_send_message_exceptions( hass: HomeAssistant, config_entry: MockConfigEntry, - smtp: MagicMock, - call_method: str, + aiosmtplib: AsyncMock, exception: Exception, translation_key: str, + call_count: int, ) -> None: """Test exceptions via notify.send_message action.""" @@ -296,7 +286,7 @@ async def test_notify_send_message_exceptions( assert config_entry.state is ConfigEntryState.LOADED - getattr(smtp, call_method).side_effect = exception + aiosmtplib.__aenter__.return_value.send_message.side_effect = exception with pytest.raises(HomeAssistantError) as e: await hass.services.async_call( @@ -310,40 +300,11 @@ async def test_notify_send_message_exceptions( ) assert e.value.translation_key == translation_key - - -@pytest.mark.usefixtures("make_msgid") -@pytest.mark.freeze_time("2026-05-03T03:09:37+00:00") -async def test_notify_retry_on_disconnect_with_broken_quit( - hass: HomeAssistant, - config_entry: MockConfigEntry, - smtp: MagicMock, -) -> None: - """Test retry succeeds when quit() raises on a dead connection.""" - - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - - smtp.sendmail.side_effect = [SMTPServerDisconnected("gone"), None] - smtp.quit.side_effect = SMTPServerDisconnected("please run connect() first") - - await hass.services.async_call( - NOTIFY_DOMAIN, - SERVICE_SEND_MESSAGE, - { - ATTR_ENTITY_ID: "notify.home_assistant_recipient", - ATTR_MESSAGE: "Hello World", - }, - blocking=True, - ) - - assert smtp.sendmail.call_count == 2 + assert aiosmtplib.__aenter__.return_value.send_message.call_count == call_count @pytest.mark.parametrize("exception", [SMTPServerDisconnected, SMTPException]) +@pytest.mark.usefixtures("aiosmtplib") async def test_legacy_notify_exception( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -375,12 +336,12 @@ async def test_legacy_notify_exception( assert smtp.sendmail.call_count == 2 -@pytest.mark.usefixtures("make_msgid", "randrange") +@pytest.mark.usefixtures("make_msgid", "smtp", "randrange") @pytest.mark.freeze_time("2026-05-03T03:09:37+00:00") async def test_smtp_send_message( hass: HomeAssistant, config_entry: MockConfigEntry, - smtp: MagicMock, + aiosmtplib: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test sending an email message via smtp.send_message action.""" @@ -411,17 +372,16 @@ async def test_smtp_send_message( assert state assert state.state == "2026-05-03T03:09:37+00:00" - assert smtp.sendmail.call_args[0][0] == "email@example.com" - assert smtp.sendmail.call_args[0][1] == "recipient@example.com" - assert smtp.sendmail.call_args[0][2] == snapshot + msg = aiosmtplib.__aenter__.return_value.send_message.call_args[0][0] + assert msg.as_string() == snapshot -@pytest.mark.usefixtures("make_msgid", "randrange") +@pytest.mark.usefixtures("make_msgid", "smtp", "randrange") @pytest.mark.freeze_time("2026-05-03T03:09:37+00:00") async def test_smtp_send_message_local_media_source( hass: HomeAssistant, config_entry: MockConfigEntry, - smtp: MagicMock, + aiosmtplib: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test sending an email message via smtp.send_message action with attachment from local media source.""" @@ -453,17 +413,16 @@ async def test_smtp_send_message_local_media_source( blocking=True, ) - assert smtp.sendmail.call_args[0][0] == "email@example.com" - assert smtp.sendmail.call_args[0][1] == "recipient@example.com" - assert smtp.sendmail.call_args[0][2] == snapshot + msg = aiosmtplib.__aenter__.return_value.send_message.call_args[0][0] + assert msg.as_string() == snapshot -@pytest.mark.usefixtures("make_msgid", "randrange") +@pytest.mark.usefixtures("make_msgid", "smtp", "randrange") @pytest.mark.freeze_time("2026-05-03T03:09:37+00:00") async def test_smtp_send_message_camera_source( hass: HomeAssistant, config_entry: MockConfigEntry, - smtp: MagicMock, + aiosmtplib: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test sending an email message via smtp.send_message action with attachment from camera source snapshot.""" @@ -500,17 +459,16 @@ async def test_smtp_send_message_camera_source( blocking=True, ) mock_get_image.assert_called_once_with(hass, "camera.demo_camera") - assert smtp.sendmail.call_args[0][0] == "email@example.com" - assert smtp.sendmail.call_args[0][1] == "recipient@example.com" - assert smtp.sendmail.call_args[0][2] == snapshot + msg = aiosmtplib.__aenter__.return_value.send_message.call_args[0][0] + assert msg.as_string() == snapshot -@pytest.mark.usefixtures("make_msgid", "randrange") +@pytest.mark.usefixtures("make_msgid", "smtp", "randrange") @pytest.mark.freeze_time("2026-05-03T03:09:37+00:00") async def test_smtp_send_message_image_source( hass: HomeAssistant, config_entry: MockConfigEntry, - smtp: MagicMock, + aiosmtplib: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test sending an email message via smtp.send_message action with attachment from image source.""" @@ -552,17 +510,16 @@ async def test_smtp_send_message_image_source( blocking=True, ) mock_get_image.assert_called_with(hass, "image.test") - assert smtp.sendmail.call_args[0][0] == "email@example.com" - assert smtp.sendmail.call_args[0][1] == "recipient@example.com" - assert smtp.sendmail.call_args[0][2] == snapshot + msg = aiosmtplib.__aenter__.return_value.send_message.call_args[0][0] + assert msg.as_string() == snapshot -@pytest.mark.usefixtures("make_msgid", "randrange") +@pytest.mark.usefixtures("make_msgid", "smtp", "randrange") @pytest.mark.freeze_time("2026-05-03T03:09:37+00:00") async def test_smtp_send_message_tts_source( hass: HomeAssistant, config_entry: MockConfigEntry, - smtp: MagicMock, + aiosmtplib: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test sending an email message via smtp.send_message action with audio attachment from tts source.""" @@ -575,7 +532,7 @@ async def test_smtp_send_message_tts_source( with patch( "homeassistant.components.tts.async_get_media_source_audio", return_value=("mp3", b"Hello World!"), - ): + ) as mock_get_media_source_audio: await hass.services.async_call( DOMAIN, SERVICE_SEND_MESSAGE, @@ -596,12 +553,14 @@ async def test_smtp_send_message_tts_source( blocking=True, ) - assert smtp.sendmail.call_args[0][0] == "email@example.com" - assert smtp.sendmail.call_args[0][1] == "recipient@example.com" - assert smtp.sendmail.call_args[0][2] == snapshot + mock_get_media_source_audio.assert_called_with( + hass, "media-source://tts/demo?message=Hello+World%21&language=en" + ) + msg = aiosmtplib.__aenter__.return_value.send_message.call_args[0][0] + assert msg.as_string() == snapshot -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") @pytest.mark.freeze_time("2026-05-03T03:09:37+00:00") async def test_smtp_send_message_media_source_not_supported( hass: HomeAssistant, @@ -650,7 +609,7 @@ async def test_smtp_send_message_media_source_not_supported( } -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") @pytest.mark.freeze_time("2026-05-03T03:09:37+00:00") async def test_smtp_send_message_media_source_missing_filename( hass: HomeAssistant, @@ -696,7 +655,7 @@ async def test_smtp_send_message_media_source_missing_filename( } -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_deprecated_legacy_notify_action( hass: HomeAssistant, config_entry: MockConfigEntry, diff --git a/tests/components/sofar/test_init.py b/tests/components/sofar/test_init.py index 098806cf82cbbd..2c1f9340fc9004 100644 --- a/tests/components/sofar/test_init.py +++ b/tests/components/sofar/test_init.py @@ -473,6 +473,45 @@ async def test_only_wired_battery_packs_become_devices( assert entity_registry.async_get(total_id).device_id == inverter.id +async def test_total_survives_a_torn_first_poll_after_reload( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_connection: MockModbusConnection, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a reload's first poll is protected by the pre-reload total.""" + mock_config_entry.add_to_hass(hass) + unit = mock_connection.for_unit(1) + unit.holding[0x068A] = 0 + unit.holding[0x068B] = 10000 # load_consumption_total -> 1000.0 kWh + + with patch( + "homeassistant.components.sofar.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_id = entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_load_consumption_total" + ) + assert entity_id is not None + assert hass.states.get(entity_id).state == "1000.0" + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # A torn read on the reload's first poll, inside the 1% dip band. + unit.holding[0x068B] = 9995 + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(entity_id).state == "1000.0" + + async def test_battery_pack_appears_once_its_block_answers( hass: HomeAssistant, freezer: FrozenDateTimeFactory, diff --git a/tests/components/solaredge_modbus/snapshots/test_select.ambr b/tests/components/solaredge_modbus/snapshots/test_select.ambr new file mode 100644 index 00000000000000..551d380175ddb4 --- /dev/null +++ b/tests/components/solaredge_modbus/snapshots/test_select.ambr @@ -0,0 +1,389 @@ +# serializer version: 1 +# name: test_selects[select.solaredge_se10000h_export_limit_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'total', + 'per_phase', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.solaredge_se10000h_export_limit_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Export limit type', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Export limit type', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'export_control_limit_type', + 'unique_id': '7E123ABC_export_control_limit_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[select.solaredge_se10000h_export_limit_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarEdge SE10000H Export limit type', + : list([ + 'total', + 'per_phase', + ]), + }), + 'context': , + 'entity_id': 'select.solaredge_se10000h_export_limit_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'total', + }) +# --- +# name: test_selects[select.solaredge_se10000h_export_limitation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'disabled', + 'export_control_export_import_meter', + 'export_control_consumption_meter', + 'production_control', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.solaredge_se10000h_export_limitation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Export limitation', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Export limitation', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'export_control_mode', + 'unique_id': '7E123ABC_export_control_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[select.solaredge_se10000h_export_limitation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarEdge SE10000H Export limitation', + : list([ + 'disabled', + 'export_control_export_import_meter', + 'export_control_consumption_meter', + 'production_control', + ]), + }), + 'context': , + 'entity_id': 'select.solaredge_se10000h_export_limitation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disabled', + }) +# --- +# name: test_selects[select.solaredge_se10000h_storage_ac_charge_policy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'disabled', + 'always', + 'fixed_energy_limit', + 'percent_of_production', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.solaredge_se10000h_storage_ac_charge_policy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Storage AC charge policy', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Storage AC charge policy', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'storage_ac_charge_policy', + 'unique_id': '7E123ABC_storage_ac_charge_policy', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[select.solaredge_se10000h_storage_ac_charge_policy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarEdge SE10000H Storage AC charge policy', + : list([ + 'disabled', + 'always', + 'fixed_energy_limit', + 'percent_of_production', + ]), + }), + 'context': , + 'entity_id': 'select.solaredge_se10000h_storage_ac_charge_policy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disabled', + }) +# --- +# name: test_selects[select.solaredge_se10000h_storage_command_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'solar_only', + 'charge_from_clipped_solar', + 'charge_from_solar', + 'charge_from_solar_and_grid', + 'discharge_to_maximize_export', + 'discharge_to_minimize_import', + 'maximize_self_consumption', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.solaredge_se10000h_storage_command_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Storage command mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Storage command mode', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'storage_command_mode', + 'unique_id': '7E123ABC_storage_command_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[select.solaredge_se10000h_storage_command_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarEdge SE10000H Storage command mode', + : list([ + 'solar_only', + 'charge_from_clipped_solar', + 'charge_from_solar', + 'charge_from_solar_and_grid', + 'discharge_to_maximize_export', + 'discharge_to_minimize_import', + 'maximize_self_consumption', + ]), + }), + 'context': , + 'entity_id': 'select.solaredge_se10000h_storage_command_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_selects[select.solaredge_se10000h_storage_control_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'disabled', + 'maximize_self_consumption', + 'time_of_use', + 'backup_only', + 'remote_control', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.solaredge_se10000h_storage_control_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Storage control mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Storage control mode', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'storage_control_mode', + 'unique_id': '7E123ABC_storage_control_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[select.solaredge_se10000h_storage_control_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarEdge SE10000H Storage control mode', + : list([ + 'disabled', + 'maximize_self_consumption', + 'time_of_use', + 'backup_only', + 'remote_control', + ]), + }), + 'context': , + 'entity_id': 'select.solaredge_se10000h_storage_control_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'maximize_self_consumption', + }) +# --- +# name: test_selects[select.solaredge_se10000h_storage_default_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'solar_only', + 'charge_from_clipped_solar', + 'charge_from_solar', + 'charge_from_solar_and_grid', + 'discharge_to_maximize_export', + 'discharge_to_minimize_import', + 'maximize_self_consumption', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.solaredge_se10000h_storage_default_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Storage default mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Storage default mode', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'storage_default_mode', + 'unique_id': '7E123ABC_storage_default_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[select.solaredge_se10000h_storage_default_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarEdge SE10000H Storage default mode', + : list([ + 'solar_only', + 'charge_from_clipped_solar', + 'charge_from_solar', + 'charge_from_solar_and_grid', + 'discharge_to_maximize_export', + 'discharge_to_minimize_import', + 'maximize_self_consumption', + ]), + }), + 'context': , + 'entity_id': 'select.solaredge_se10000h_storage_default_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'solar_only', + }) +# --- diff --git a/tests/components/solaredge_modbus/test_select.py b/tests/components/solaredge_modbus/test_select.py new file mode 100644 index 00000000000000..d6380be420e075 --- /dev/null +++ b/tests/components/solaredge_modbus/test_select.py @@ -0,0 +1,142 @@ +"""Tests for the SolarEdge Modbus select entities.""" + +from unittest.mock import patch + +from modbus_connection.mock import MockModbusUnit +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + ATTR_OPTIONS, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +CONTROL_MODE_ENTITY = "select.solaredge_se10000h_storage_control_mode" +CONTROL_MODE_REGISTER = 57348 +EXPORT_MODE_ENTITY = "select.solaredge_se10000h_export_limitation" + + +async def _setup_select_platform(hass: HomeAssistant, entry: MockConfigEntry) -> None: + with patch( + "homeassistant.components.solaredge_modbus.PLATFORMS", [Platform.SELECT] + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_selects( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """All select entities and their states match the snapshot.""" + await _setup_select_platform(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_limit_type_disabled_by_default( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """How the site limit is counted is part of the installer's setup.""" + await _setup_select_platform(hass, mock_config_entry) + + entity_id = "select.solaredge_se10000h_export_limit_type" + + assert hass.states.get(entity_id) is None + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +async def test_select_option( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """Selecting an option writes the mode to the device and updates the state.""" + await _setup_select_platform(hass, mock_config_entry) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: CONTROL_MODE_ENTITY, ATTR_OPTION: "time_of_use"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(CONTROL_MODE_ENTITY) + assert state is not None + assert state.state == "time_of_use" + assert mock_modbus_unit.holding[CONTROL_MODE_REGISTER] == 2 + + +async def test_export_mode_keeps_the_mode_the_inverter_is_set_to( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A mode the inverter is set to is offered even where it does not fit. + + The register is the authority on what the inverter is set to, whatever an + installer or the SolarEdge app left behind, and an entity may not report a + state outside its own options. + """ + # Remove the meter from the register image (no meter model = absent). + mock_modbus_unit.holding[40188] = 0 + # ...while the inverter is set to a meter-based mode: bit 1 of the mode. + mock_modbus_unit.holding[57344] = 0b10 + + await _setup_select_platform(hass, mock_config_entry) + + state = hass.states.get(EXPORT_MODE_ENTITY) + assert state is not None + assert state.state == "export_control_consumption_meter" + assert state.state in state.attributes[ATTR_OPTIONS] + + +async def test_export_mode_without_a_meter_hides_the_meter_modes( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """Limiting export by a meter reading needs a meter to read.""" + # Remove the meter from the register image (no meter model = absent). + mock_modbus_unit.holding[40188] = 0 + # ...with export limiting switched off, so no mode has to be kept. + mock_modbus_unit.holding[57344] = 0 + + await _setup_select_platform(hass, mock_config_entry) + + state = hass.states.get(EXPORT_MODE_ENTITY) + assert state is not None + assert state.state == "disabled" + assert state.attributes[ATTR_OPTIONS] == ["disabled", "production_control"] + + +async def test_export_mode_options_with_meter( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """A site with a meter offers the meter-based export limitation modes too.""" + await _setup_select_platform(hass, mock_config_entry) + + state = hass.states.get(EXPORT_MODE_ENTITY) + assert state is not None + assert state.attributes[ATTR_OPTIONS] == [ + "disabled", + "export_control_export_import_meter", + "export_control_consumption_meter", + "production_control", + ] diff --git a/tests/components/sunsynk/__init__.py b/tests/components/sunsynk/__init__.py new file mode 100644 index 00000000000000..d6ffe145b589c7 --- /dev/null +++ b/tests/components/sunsynk/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the Sunsynk integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the Sunsynk integration for testing.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/sunsynk/conftest.py b/tests/components/sunsynk/conftest.py new file mode 100644 index 00000000000000..784be487d0567d --- /dev/null +++ b/tests/components/sunsynk/conftest.py @@ -0,0 +1,83 @@ +"""Fixtures for the Sunsynk tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from sunsynk.battery import Battery +from sunsynk.grid import Grid +from sunsynk.input import Input +from sunsynk.inverter import Inverter +from sunsynk.load import Load +from sunsynk.user import User + +from homeassistant.components.sunsynk.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + +USERNAME = "test@example.com" +PASSWORD = "test-password" +USER_ID = "281092" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sunsynk.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_sunsynk_client() -> Generator[AsyncMock]: + """Mock the Sunsynk API client.""" + with ( + patch( + "homeassistant.components.sunsynk.SunsynkClient", autospec=True + ) as mock_client, + patch( + "homeassistant.components.sunsynk.config_flow.SunsynkClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_user.return_value = User( + load_json_object_fixture("user.json", DOMAIN) + ) + client.get_inverters.return_value = [ + Inverter(inverter) + for inverter in load_json_array_fixture("inverters.json", DOMAIN) + ] + client.get_inverter_realtime_battery.side_effect = lambda sn: Battery( + load_json_object_fixture( + "battery.json" if sn == "1029384756" else "battery_absent.json", + DOMAIN, + ) + ) + client.get_inverter_realtime_grid.side_effect = lambda sn: Grid( + load_json_object_fixture("grid.json", DOMAIN) + ) + client.get_inverter_realtime_input.side_effect = lambda sn: Input( + load_json_object_fixture("input.json", DOMAIN) + ) + client.get_inverter_realtime_load.side_effect = lambda sn: Load( + load_json_object_fixture("load.json", DOMAIN) + ) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=USERNAME, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + unique_id=USER_ID, + ) diff --git a/tests/components/sunsynk/fixtures/battery.json b/tests/components/sunsynk/fixtures/battery.json new file mode 100644 index 00000000000000..227583121df92b --- /dev/null +++ b/tests/components/sunsynk/fixtures/battery.json @@ -0,0 +1,40 @@ +{ + "time": null, + "etodayChg": "1.1", + "etodayDischg": "0.6", + "emonthChg": "7.5", + "emonthDischg": "6.2", + "eyearChg": "7.5", + "eyearDischg": "6.2", + "etotalChg": "188.5", + "etotalDischg": "147.9", + "type": 1, + "power": -18, + "capacity": "100.0", + "correctCap": 100, + "current": "-0.4", + "voltage": "53.3", + "temp": "18.7", + "soc": "20.0", + "chargeVolt": 56.1, + "dischargeVolt": 0.0, + "chargeCurrentLimit": 50.0, + "dischargeCurrentLimit": 50.0, + "maxChargeCurrentLimit": 0.0, + "maxDischargeCurrentLimit": 0.0, + "status": 1, + "batterySoc1": 0.0, + "batteryCurrent1": 0.0, + "batteryVolt1": 0.0, + "batteryPower1": 0.0, + "batteryTemp1": 0.0, + "batteryStatus2": 0, + "batterySoc2": null, + "batteryCurrent2": null, + "batteryVolt2": null, + "batteryPower2": null, + "batteryTemp2": null, + "numberOfBatteries": null, + "batt1Factory": null, + "batt2Factory": null +} diff --git a/tests/components/sunsynk/fixtures/battery_absent.json b/tests/components/sunsynk/fixtures/battery_absent.json new file mode 100644 index 00000000000000..8a477c25b4dd14 --- /dev/null +++ b/tests/components/sunsynk/fixtures/battery_absent.json @@ -0,0 +1,40 @@ +{ + "time": null, + "etodayChg": "0.0", + "etodayDischg": "0.0", + "emonthChg": "0.0", + "emonthDischg": "0.0", + "eyearChg": "0.0", + "eyearDischg": "0.0", + "etotalChg": "0.0", + "etotalDischg": "0.0", + "type": 0, + "power": 0, + "capacity": "0.0", + "correctCap": 0, + "current": "0.0", + "voltage": "0.0", + "temp": "0.0", + "soc": "0.0", + "chargeVolt": 0.0, + "dischargeVolt": 0.0, + "chargeCurrentLimit": 0.0, + "dischargeCurrentLimit": 0.0, + "maxChargeCurrentLimit": 0.0, + "maxDischargeCurrentLimit": 0.0, + "status": 0, + "batterySoc1": null, + "batteryCurrent1": null, + "batteryVolt1": null, + "batteryPower1": null, + "batteryTemp1": null, + "batteryStatus2": null, + "batterySoc2": null, + "batteryCurrent2": null, + "batteryVolt2": null, + "batteryPower2": null, + "batteryTemp2": null, + "numberOfBatteries": null, + "batt1Factory": null, + "batt2Factory": null +} diff --git a/tests/components/sunsynk/fixtures/grid.json b/tests/components/sunsynk/fixtures/grid.json new file mode 100644 index 00000000000000..97b3bd96ec3c42 --- /dev/null +++ b/tests/components/sunsynk/fixtures/grid.json @@ -0,0 +1,30 @@ +{ + "vip": [ + { + "volt": "233.6", + "current": "0.8", + "power": 200 + }, + { + "volt": "234.1", + "current": "1.6", + "power": 390 + }, + { + "volt": "232.9", + "current": "0.1", + "power": 20 + } + ], + "pac": 610, + "qac": 0, + "fac": 50.08, + "pf": 1.0, + "status": 1, + "etodayFrom": "12.2", + "etodayTo": "0.0", + "etotalFrom": "998.5", + "etotalTo": "48.2", + "limiterPowerArr": [200, 390, 20], + "limiterTotalPower": 610 +} diff --git a/tests/components/sunsynk/fixtures/input.json b/tests/components/sunsynk/fixtures/input.json new file mode 100644 index 00000000000000..fa30fabd6794ff --- /dev/null +++ b/tests/components/sunsynk/fixtures/input.json @@ -0,0 +1,28 @@ +{ + "pac": 9, + "pvIV": [ + { + "id": null, + "pvNo": 1, + "vpv": "91.5", + "ipv": "0.1", + "ppv": "9.0", + "todayPv": "0.0", + "sn": "1029384756", + "time": "2023-01-07 16:50:17" + }, + { + "id": null, + "pvNo": 2, + "vpv": "2.4", + "ipv": "0.1", + "ppv": "0.0", + "todayPv": "0.0", + "sn": "1029384756", + "time": "2023-01-07 16:50:17" + } + ], + "mpptIV": [], + "etoday": 1.8, + "etotal": 375.2 +} diff --git a/tests/components/sunsynk/fixtures/inverters.json b/tests/components/sunsynk/fixtures/inverters.json new file mode 100644 index 00000000000000..a393fea276ec9d --- /dev/null +++ b/tests/components/sunsynk/fixtures/inverters.json @@ -0,0 +1,72 @@ +[ + { + "sn": "1029384756", + "alias": "Garage inverter", + "gsn": "E0192837465", + "status": 1, + "type": 2, + "commTypeName": "RS485", + "custCode": 29, + "version": { + "masterVer": "2.3.7.4", + "softVer": "1.5.1.5", + "hardVer": "", + "hmiVer": "E.4.2.4", + "bmsVer": "" + }, + "model": "SUNSYNK-5K-SG04LP1", + "equipMode": null, + "pac": 61, + "etoday": 1.7, + "etotal": 375.1, + "updateAt": "2023-01-07T15:40:02Z", + "opened": 1, + "plant": { + "id": 12345, + "name": "John Smith", + "type": 2, + "master": null, + "installer": null, + "email": null, + "phone": null + }, + "gatewayVO": { + "gsn": "E0192837465", + "status": 2 + }, + "sunsynkEquip": true, + "protocolIdentifier": "2" + }, + { + "sn": "2938475610", + "alias": "", + "gsn": "E0192837466", + "status": 1, + "type": 2, + "commTypeName": "RS485", + "custCode": 29, + "version": null, + "model": "", + "equipMode": null, + "pac": 61, + "etoday": 1.7, + "etotal": 375.1, + "updateAt": "2023-01-07T15:40:02Z", + "opened": 1, + "plant": { + "id": 12345, + "name": "John Smith", + "type": 2, + "master": null, + "installer": null, + "email": null, + "phone": null + }, + "gatewayVO": { + "gsn": "E0192837466", + "status": 2 + }, + "sunsynkEquip": true, + "protocolIdentifier": "2" + } +] diff --git a/tests/components/sunsynk/fixtures/load.json b/tests/components/sunsynk/fixtures/load.json new file mode 100644 index 00000000000000..ae70f5c3682b9f --- /dev/null +++ b/tests/components/sunsynk/fixtures/load.json @@ -0,0 +1,28 @@ +{ + "totalUsed": 3133.1, + "dailyUsed": 34.7, + "vip": [ + { + "volt": "246.6", + "current": "0.0", + "power": 1200 + }, + { + "volt": "245.9", + "current": "0.0", + "power": 2000 + }, + { + "volt": "246.2", + "current": "0.0", + "power": 227 + } + ], + "totalPower": 3427, + "smartLoadStatus": -1, + "loadFac": 50.01, + "upsPowerL1": 5.0, + "upsPowerL2": 0.0, + "upsPowerL3": 0.0, + "upsPowerTotal": 5.0 +} diff --git a/tests/components/sunsynk/fixtures/user.json b/tests/components/sunsynk/fixtures/user.json new file mode 100644 index 00000000000000..0c3fa2e80baa5e --- /dev/null +++ b/tests/components/sunsynk/fixtures/user.json @@ -0,0 +1,14 @@ +{ + "id": 281092, + "nickname": "test@example.com", + "avatar": "https://sunsynk-s3.s3.eu-west-2.amazonaws.com/avatar/20210126155052363929.png", + "gender": 1, + "mobile": null, + "createAt": "2022-10-03T15:39:04Z", + "type": null, + "tempUnit": "\u2103", + "company": null, + "userSrc": "sunsynk", + "email": "test@example.com", + "sex": 1 +} diff --git a/tests/components/sunsynk/snapshots/test_init.ambr b/tests/components/sunsynk/snapshots/test_init.ambr new file mode 100644 index 00000000000000..477609ec4b8c95 --- /dev/null +++ b/tests/components/sunsynk/snapshots/test_init.ambr @@ -0,0 +1,89 @@ +# serializer version: 1 +# name: test_devices + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'sunsynk', + '1029384756', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Sunsynk', + 'model': 'SUNSYNK-5K-SG04LP1', + 'model_id': None, + 'name': 'Garage inverter', + 'name_by_user': None, + 'serial_number': '1029384756', + 'sw_version': '1.5.1.5', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'sunsynk', + '2938475610', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Sunsynk', + 'model': None, + 'model_id': None, + 'name': 'Inverter 2938475610', + 'name_by_user': None, + 'serial_number': '2938475610', + 'sw_version': None, + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'sunsynk', + '1029384756_battery', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Sunsynk', + 'model': None, + 'model_id': None, + 'name': 'Battery 1029384756', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': None, + 'via_device_id': , + }), + ]) +# --- diff --git a/tests/components/sunsynk/snapshots/test_sensor.ambr b/tests/components/sunsynk/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..844fe07a0a5442 --- /dev/null +++ b/tests/components/sunsynk/snapshots/test_sensor.ambr @@ -0,0 +1,1912 @@ +# serializer version: 1 +# name: test_sensors[sensor.battery_1029384756_charge_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_1029384756_charge_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charge today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge today', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charge_today', + 'unique_id': '1029384756_battery_charge_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1029384756_charge_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Battery 1029384756 Charge today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1029384756_charge_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1', + }) +# --- +# name: test_sensors[sensor.battery_1029384756_charge_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_1029384756_charge_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charge total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge total', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charge_total', + 'unique_id': '1029384756_battery_charge_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1029384756_charge_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Battery 1029384756 Charge total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1029384756_charge_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '188.5', + }) +# --- +# name: test_sensors[sensor.battery_1029384756_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_1029384756_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': '1029384756_battery_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1029384756_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Battery 1029384756 Current', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1029384756_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-0.4', + }) +# --- +# name: test_sensors[sensor.battery_1029384756_discharge_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_1029384756_discharge_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Discharge today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Discharge today', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'discharge_today', + 'unique_id': '1029384756_battery_discharge_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1029384756_discharge_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Battery 1029384756 Discharge today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1029384756_discharge_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.6', + }) +# --- +# name: test_sensors[sensor.battery_1029384756_discharge_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_1029384756_discharge_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Discharge total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Discharge total', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'discharge_total', + 'unique_id': '1029384756_battery_discharge_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1029384756_discharge_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Battery 1029384756 Discharge total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1029384756_discharge_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '147.9', + }) +# --- +# name: test_sensors[sensor.battery_1029384756_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_1029384756_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': '1029384756_battery_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1029384756_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Battery 1029384756 Power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1029384756_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-18.0', + }) +# --- +# name: test_sensors[sensor.battery_1029384756_state_of_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_1029384756_state_of_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'State of charge', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State of charge', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'state_of_charge', + 'unique_id': '1029384756_battery_state_of_charge', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.battery_1029384756_state_of_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery', + : 'Battery 1029384756 State of charge', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.battery_1029384756_state_of_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_sensors[sensor.battery_1029384756_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_1029384756_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '1029384756_battery_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1029384756_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Battery 1029384756 Temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1029384756_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.7', + }) +# --- +# name: test_sensors[sensor.battery_1029384756_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_1029384756_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': '1029384756_battery_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1029384756_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Battery 1029384756 Voltage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1029384756_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53.3', + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_export_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garage_inverter_grid_export_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid export today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid export today', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_export_today', + 'unique_id': '1029384756_grid_export_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_export_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Garage inverter Grid export today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_grid_export_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_export_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garage_inverter_grid_export_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid export total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid export total', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_export_total', + 'unique_id': '1029384756_grid_export_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_export_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Garage inverter Grid export total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_grid_export_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48.2', + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.garage_inverter_grid_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid frequency', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid frequency', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_frequency', + 'unique_id': '1029384756_grid_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'frequency', + : 'Garage inverter Grid frequency', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_grid_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.08', + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_import_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garage_inverter_grid_import_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid import today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid import today', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_import_today', + 'unique_id': '1029384756_grid_import_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_import_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Garage inverter Grid import today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_grid_import_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.2', + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_import_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garage_inverter_grid_import_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid import total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid import total', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_import_total', + 'unique_id': '1029384756_grid_import_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_import_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Garage inverter Grid import total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_grid_import_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '998.5', + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garage_inverter_grid_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid power', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_power', + 'unique_id': '1029384756_grid_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Garage inverter Grid power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_grid_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '610.0', + }) +# --- +# name: test_sensors[sensor.garage_inverter_load_energy_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garage_inverter_load_energy_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Load energy today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Load energy today', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'load_energy_today', + 'unique_id': '1029384756_load_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_load_energy_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Garage inverter Load energy today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_load_energy_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34.7', + }) +# --- +# name: test_sensors[sensor.garage_inverter_load_energy_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garage_inverter_load_energy_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Load energy total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Load energy total', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'load_energy_total', + 'unique_id': '1029384756_load_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_load_energy_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Garage inverter Load energy total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_load_energy_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3133.1', + }) +# --- +# name: test_sensors[sensor.garage_inverter_load_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garage_inverter_load_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Load power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Load power', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'load_power', + 'unique_id': '1029384756_load_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_load_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Garage inverter Load power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_load_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3427.0', + }) +# --- +# name: test_sensors[sensor.garage_inverter_solar_energy_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garage_inverter_solar_energy_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Solar energy today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar energy today', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'solar_energy_today', + 'unique_id': '1029384756_solar_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_solar_energy_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Garage inverter Solar energy today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_solar_energy_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.8', + }) +# --- +# name: test_sensors[sensor.garage_inverter_solar_energy_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garage_inverter_solar_energy_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Solar energy total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar energy total', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'solar_energy_total', + 'unique_id': '1029384756_solar_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_solar_energy_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Garage inverter Solar energy total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_solar_energy_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '375.2', + }) +# --- +# name: test_sensors[sensor.garage_inverter_solar_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garage_inverter_solar_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Solar power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar power', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'solar_power', + 'unique_id': '1029384756_solar_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_solar_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Garage inverter Solar power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_solar_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.0', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_export_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_2938475610_grid_export_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid export today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid export today', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_export_today', + 'unique_id': '2938475610_grid_export_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_export_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Inverter 2938475610 Grid export today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_grid_export_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_export_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_2938475610_grid_export_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid export total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid export total', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_export_total', + 'unique_id': '2938475610_grid_export_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_export_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Inverter 2938475610 Grid export total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_grid_export_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48.2', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.inverter_2938475610_grid_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid frequency', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid frequency', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_frequency', + 'unique_id': '2938475610_grid_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'frequency', + : 'Inverter 2938475610 Grid frequency', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_grid_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.08', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_import_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_2938475610_grid_import_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid import today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid import today', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_import_today', + 'unique_id': '2938475610_grid_import_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_import_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Inverter 2938475610 Grid import today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_grid_import_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.2', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_import_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_2938475610_grid_import_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid import total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid import total', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_import_total', + 'unique_id': '2938475610_grid_import_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_import_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Inverter 2938475610 Grid import total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_grid_import_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '998.5', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_2938475610_grid_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid power', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_power', + 'unique_id': '2938475610_grid_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Inverter 2938475610 Grid power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_grid_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '610.0', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_load_energy_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_2938475610_load_energy_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Load energy today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Load energy today', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'load_energy_today', + 'unique_id': '2938475610_load_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_load_energy_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Inverter 2938475610 Load energy today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_load_energy_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34.7', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_load_energy_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_2938475610_load_energy_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Load energy total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Load energy total', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'load_energy_total', + 'unique_id': '2938475610_load_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_load_energy_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Inverter 2938475610 Load energy total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_load_energy_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3133.1', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_load_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_2938475610_load_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Load power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Load power', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'load_power', + 'unique_id': '2938475610_load_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_load_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Inverter 2938475610 Load power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_load_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3427.0', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_solar_energy_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_2938475610_solar_energy_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Solar energy today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar energy today', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'solar_energy_today', + 'unique_id': '2938475610_solar_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_solar_energy_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Inverter 2938475610 Solar energy today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_solar_energy_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.8', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_solar_energy_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_2938475610_solar_energy_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Solar energy total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar energy total', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'solar_energy_total', + 'unique_id': '2938475610_solar_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_solar_energy_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Inverter 2938475610 Solar energy total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_solar_energy_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '375.2', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_solar_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_2938475610_solar_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Solar power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar power', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'solar_power', + 'unique_id': '2938475610_solar_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_solar_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Inverter 2938475610 Solar power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_solar_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.0', + }) +# --- diff --git a/tests/components/sunsynk/test_config_flow.py b/tests/components/sunsynk/test_config_flow.py new file mode 100644 index 00000000000000..264ea0ce89192f --- /dev/null +++ b/tests/components/sunsynk/test_config_flow.py @@ -0,0 +1,97 @@ +"""Test the Sunsynk config flow.""" + +from unittest.mock import AsyncMock + +import pytest +from sunsynk.exceptions import SunsynkAuthenticationError, SunsynkConnectionError + +from homeassistant.components.sunsynk.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo + +from .conftest import PASSWORD, USER_ID, USERNAME + +from tests.common import MockConfigEntry + +DHCP_SERVICE_INFO = DhcpServiceInfo( + hostname="e-linter", ip="192.168.1.20", macaddress="1091a8aabbcc" +) + + +async def test_full_user_flow( + hass: HomeAssistant, + mock_sunsynk_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full user 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 not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == USERNAME + assert result["data"] == {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + assert result["result"].unique_id == USER_ID + assert len(mock_sunsynk_client.get_user.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_sunsynk_client", "mock_setup_entry") +async def test_duplicate_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the flow aborts when the account is 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"], + user_input={CONF_USERNAME: "other@example.com", CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + pytest.param(SunsynkAuthenticationError, "invalid_auth", id="invalid_auth"), + pytest.param(SunsynkConnectionError, "cannot_connect", id="cannot_connect"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_errors( + hass: HomeAssistant, + mock_sunsynk_client: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test the user flow shows an error and can recover.""" + mock_sunsynk_client.get_user.side_effect = exception + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_sunsynk_client.get_user.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/sunsynk/test_init.py b/tests/components/sunsynk/test_init.py new file mode 100644 index 00000000000000..fae0af9b3b65e9 --- /dev/null +++ b/tests/components/sunsynk/test_init.py @@ -0,0 +1,75 @@ +"""Test the Sunsynk integration setup.""" + +from unittest.mock import AsyncMock + +import pytest +from sunsynk.exceptions import SunsynkAuthenticationError, SunsynkConnectionError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_sunsynk_client") +async def test_load_unload_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the config entry loads and unloads.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + 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 + + +@pytest.mark.parametrize( + ("method", "exception", "result"), + [ + ("get_inverters", SunsynkConnectionError, ConfigEntryState.SETUP_RETRY), + ("get_inverters", SunsynkAuthenticationError, ConfigEntryState.SETUP_ERROR), + ( + "get_inverter_realtime_grid", + SunsynkConnectionError, + ConfigEntryState.SETUP_RETRY, + ), + ( + "get_inverter_realtime_grid", + SunsynkAuthenticationError, + ConfigEntryState.SETUP_ERROR, + ), + ], +) +async def test_setup_connection_error( + hass: HomeAssistant, + mock_sunsynk_client: AsyncMock, + mock_config_entry: MockConfigEntry, + method: str, + exception: Exception, + result: ConfigEntryState, +) -> None: + """Test the config entry retries when the API cannot be reached.""" + getattr(mock_sunsynk_client, method).side_effect = exception + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is result + + +@pytest.mark.usefixtures("mock_sunsynk_client") +async def test_devices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test a device is created for each inverter.""" + await setup_integration(hass, mock_config_entry) + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert len(devices) == 3 + assert devices == snapshot diff --git a/tests/components/sunsynk/test_sensor.py b/tests/components/sunsynk/test_sensor.py new file mode 100644 index 00000000000000..ce27d694c571a6 --- /dev/null +++ b/tests/components/sunsynk/test_sensor.py @@ -0,0 +1,128 @@ +"""Test the Sunsynk sensors.""" + +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from sunsynk.exceptions import SunsynkConnectionError +from sunsynk.grid import Grid +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.sunsynk.const import DOMAIN, SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID_GRID_POWER = "sensor.garage_inverter_grid_power" +ENTITY_ID_GRID_POWER_2 = "sensor.inverter_2938475610_grid_power" + + +@pytest.mark.usefixtures("mock_sunsynk_client", "entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the sensor entities.""" + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_sensors_unavailable_on_error( + hass: HomeAssistant, + mock_sunsynk_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the sensors become unavailable when an update fails.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get(ENTITY_ID_GRID_POWER).state == "610.0" + + grid = mock_sunsynk_client.get_inverter_realtime_grid.side_effect + mock_sunsynk_client.get_inverter_realtime_grid.side_effect = SunsynkConnectionError + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert hass.states.get(ENTITY_ID_GRID_POWER).state == STATE_UNAVAILABLE + + mock_sunsynk_client.get_inverter_realtime_grid.side_effect = grid + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert hass.states.get(ENTITY_ID_GRID_POWER).state == "610.0" + + +async def test_one_inverter_unavailable( + hass: HomeAssistant, + mock_sunsynk_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a failing inverter does not affect the other inverters.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get(ENTITY_ID_GRID_POWER).state == "610.0" + assert hass.states.get(ENTITY_ID_GRID_POWER_2).state == "610.0" + + grid = mock_sunsynk_client.get_inverter_realtime_grid.side_effect + + def failing_grid(sn: str) -> Grid: + if sn == "2938475610": + raise SunsynkConnectionError + return grid(sn) + + mock_sunsynk_client.get_inverter_realtime_grid.side_effect = failing_grid + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert hass.states.get(ENTITY_ID_GRID_POWER).state == "610.0" + assert hass.states.get(ENTITY_ID_GRID_POWER_2).state == STATE_UNAVAILABLE + + mock_sunsynk_client.get_inverter_realtime_grid.side_effect = grid + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert hass.states.get(ENTITY_ID_GRID_POWER_2).state == "610.0" + + +@pytest.mark.usefixtures("mock_sunsynk_client") +async def test_power_sensors_use_total_of_all_phases( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the grid and load power sensors report the total across all phases.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get(ENTITY_ID_GRID_POWER).state == "610.0" + assert hass.states.get("sensor.garage_inverter_load_power").state == "3427.0" + + +@pytest.mark.usefixtures("mock_sunsynk_client") +async def test_no_battery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test an inverter without a battery gets no battery device or entities.""" + await setup_integration(hass, mock_config_entry) + entry_id = mock_config_entry.entry_id + inverter = device_registry.async_get_device_by_identifier( + (DOMAIN, "1029384756"), entry_id + ) + battery = device_registry.async_get_device_by_identifier( + (DOMAIN, "1029384756_battery"), entry_id + ) + assert inverter is not None + assert battery is not None + assert battery.via_device_id == inverter.id + assert hass.states.get("sensor.battery_1029384756_state_of_charge").state == "20.0" + + assert ( + device_registry.async_get_device_by_identifier( + (DOMAIN, "2938475610_battery"), entry_id + ) + is None + ) + assert hass.states.get("sensor.battery_2938475610_state_of_charge") is None diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 3000adda996a7c..9d3b7fb00bc207 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -786,9 +786,15 @@ async def test_duplicate_entry(hass: HomeAssistant) -> None: # test: import first entry success result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=data, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=data, ) await hass.async_block_till_done() @@ -801,9 +807,15 @@ async def test_duplicate_entry(hass: HomeAssistant) -> None: # test: import 2nd entry failed due to duplicate result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=data, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=data, ) await hass.async_block_till_done() diff --git a/tests/components/tolo/test_config_flow.py b/tests/components/tolo/test_config_flow.py index 34e4c2202673c9..1d14deda880b99 100644 --- a/tests/components/tolo/test_config_flow.py +++ b/tests/components/tolo/test_config_flow.py @@ -60,9 +60,14 @@ async def test_user_with_timed_out_host(hass: HomeAssistant, toloclient: Mock) - toloclient().get_status.side_effect = ToloCommunicationError result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: "127.0.0.1"}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.1"} ) assert result["type"] is FlowResultType.FORM diff --git a/tests/components/trafikverket_camera/conftest.py b/tests/components/trafikverket_camera/conftest.py index 91ce963e6582f3..6320426db33a78 100644 --- a/tests/components/trafikverket_camera/conftest.py +++ b/tests/components/trafikverket_camera/conftest.py @@ -24,8 +24,9 @@ async def load_integration_from_entry( get_camera: CameraInfoModel, ) -> MockConfigEntry: """Set up the Trafikverket Camera integration in Home Assistant.""" + aioclient_mock.get("https://www.testurl.com/test_photo.jpg", content=b"0123456789") aioclient_mock.get( - "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + "https://www.testurl.com/test_photo_fullsize.jpg", content=b"0123456789" ) config_entry = MockConfigEntry( @@ -57,17 +58,24 @@ def fixture_get_camera() -> CameraInfoModel: return CameraInfoModel( camera_name="Test Camera", camera_id="1234", + camera_group="Test Camera Group", + camera_type="Road", active=True, deleted=False, description="Test Camera for testing", direction="180", - fullsizephoto=True, + has_fullsizephoto=True, + has_sketchimage=True, + icon="12", location="Test location", modified=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), phototime=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), photourl="https://www.testurl.com/test_photo.jpg", + photourlfullsize="https://www.testurl.com/test_photo_fullsize.jpg", + photourlsketch="https://www.testurl.com/test_photo_sketch.jpg", + photourlthumbnail="https://www.testurl.com/test_photo_thumbnail.jpg", status="Running", - camera_type="Road", + wgs84="POINT (12.345678 56.789012)", ) @@ -78,17 +86,24 @@ def fixture_get_camera2() -> CameraInfoModel: return CameraInfoModel( camera_name="Test Camera2", camera_id="5678", + camera_group="Test Camera Group2", + camera_type="Road", active=True, deleted=False, description="Test Camera for testing2", direction="180", - fullsizephoto=True, + has_fullsizephoto=True, + has_sketchimage=True, + icon="12", location="Test location2", modified=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), phototime=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), photourl="https://www.testurl.com/test_photo2.jpg", + photourlfullsize="https://www.testurl.com/test_photo2_fullsize.jpg", + photourlsketch="https://www.testurl.com/test_photo2_sketch.jpg", + photourlthumbnail="https://www.testurl.com/test_photo2_thumbnail.jpg", status="Running", - camera_type="Road", + wgs84="POINT (12.345678 56.789012)", ) @@ -100,32 +115,46 @@ def fixture_get_cameras() -> CameraInfoModel: CameraInfoModel( camera_name="Test Camera", camera_id="1234", + camera_group="Test Camera Group", + camera_type="Road", active=True, deleted=False, description="Test Camera for testing", direction="180", - fullsizephoto=True, - location="Test location", + has_fullsizephoto=True, + has_sketchimage=True, + icon="12", + location="Test location2", modified=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), phototime=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), photourl="https://www.testurl.com/test_photo.jpg", + photourlfullsize="https://www.testurl.com/test_photo_fullsize.jpg", + photourlsketch="https://www.testurl.com/test_photo_sketch.jpg", + photourlthumbnail="https://www.testurl.com/test_photo_thumbnail.jpg", status="Running", - camera_type="Road", + wgs84="POINT (13.345678 56.789012)", ), CameraInfoModel( camera_name="Test Camera2", camera_id="5678", + camera_group="Test Camera Group2", + camera_type="Road", active=True, deleted=False, description="Test Camera for testing2", direction="180", - fullsizephoto=True, + has_fullsizephoto=True, + has_sketchimage=True, + icon="12", location="Test location2", modified=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), phototime=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), photourl="https://www.testurl.com/test_photo2.jpg", + photourlfullsize="https://www.testurl.com/test_photo2_fullsize.jpg", + photourlsketch="https://www.testurl.com/test_photo2_sketch.jpg", + photourlthumbnail="https://www.testurl.com/test_photo2_thumbnail.jpg", status="Running", - camera_type="Road", + wgs84="POINT (12.345678 56.789012)", ), ] @@ -137,15 +166,22 @@ def fixture_get_camera_no_location() -> CameraInfoModel: return CameraInfoModel( camera_name="Test Camera", camera_id="1234", + camera_group="Test Camera Group", + camera_type="Road", active=True, deleted=False, description="Test Camera for testing", direction="180", - fullsizephoto=True, - location=None, + has_fullsizephoto=True, + has_sketchimage=True, + icon="12", + location="Test location2", modified=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), phototime=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), photourl="https://www.testurl.com/test_photo.jpg", + photourlfullsize="https://www.testurl.com/test_photo_fullsize.jpg", + photourlsketch="https://www.testurl.com/test_photo_sketch.jpg", + photourlthumbnail="https://www.testurl.com/test_photo_thumbnail.jpg", status="Running", - camera_type="Road", + wgs84="POINT (12.345678 56.789012)", ) diff --git a/tests/components/trafikverket_camera/test_coordinator.py b/tests/components/trafikverket_camera/test_coordinator.py index d83c7d7c79cdb2..0a9b672da10f67 100644 --- a/tests/components/trafikverket_camera/test_coordinator.py +++ b/tests/components/trafikverket_camera/test_coordinator.py @@ -30,7 +30,7 @@ async def test_coordinator( ) -> None: """Test the Trafikverket Camera coordinator.""" aioclient_mock.get( - "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + "https://www.testurl.com/test_photo_fullsize.jpg", content=b"0123456789" ) entry = MockConfigEntry( diff --git a/tests/components/trafikverket_camera/test_init.py b/tests/components/trafikverket_camera/test_init.py index 0ec2ef307736b9..8d708d46c9ec42 100644 --- a/tests/components/trafikverket_camera/test_init.py +++ b/tests/components/trafikverket_camera/test_init.py @@ -24,7 +24,7 @@ async def test_setup_entry( ) -> None: """Test setup entry.""" aioclient_mock.get( - "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + "https://www.testurl.com/test_photo_fullsize.jpg", content=b"0123456789" ) entry = MockConfigEntry( @@ -56,7 +56,7 @@ async def test_unload_entry( ) -> None: """Test unload an entry.""" aioclient_mock.get( - "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + "https://www.testurl.com/test_photo_fullsize.jpg", content=b"0123456789" ) entry = MockConfigEntry( @@ -90,7 +90,7 @@ async def test_migrate_entry( ) -> None: """Test migrate entry to version 2.""" aioclient_mock.get( - "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + "https://www.testurl.com/test_photo_fullsize.jpg", content=b"0123456789" ) entry = MockConfigEntry( @@ -204,17 +204,24 @@ async def test_migrate_entry_fails_no_id( _camera = CameraInfoModel( camera_name="Test_camera", camera_id=None, + camera_group="Test Camera Group", + camera_type="Road", active=True, deleted=False, description="Test Camera for testing", direction="180", - fullsizephoto=True, - location="Test location", + has_fullsizephoto=True, + has_sketchimage=True, + icon="12", + location="Test location2", modified=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), phototime=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), photourl="https://www.testurl.com/test_photo.jpg", + photourlfullsize="https://www.testurl.com/test_photo_fullsize.jpg", + photourlsketch="https://www.testurl.com/test_photo_sketch.jpg", + photourlthumbnail="https://www.testurl.com/test_photo_thumbnail.jpg", status="Running", - camera_type="Road", + wgs84="POINT (12.345678 56.789012)", ) with patch( diff --git a/tests/components/trafikverket_ferry/test_config_flow.py b/tests/components/trafikverket_ferry/test_config_flow.py index bd16dfb4e7a379..e2ea3ca6c5f5a9 100644 --- a/tests/components/trafikverket_ferry/test_config_flow.py +++ b/tests/components/trafikverket_ferry/test_config_flow.py @@ -63,6 +63,49 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "eker\u00f6-slagsta-10:00-['mon', 'fri']" +async def test_no_time(hass: HomeAssistant) -> None: + """Test flow without specify time.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.trafikverket_ferry.config_flow.TrafikverketFerry.async_get_next_ferry_stop", + ), + patch( + "homeassistant.components.trafikverket_ferry.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Ekerö", + CONF_TO: "Slagsta", + CONF_WEEKDAY: ["mon", "fri"], + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Ekerö to Slagsta" + assert result2["data"] == { + "api_key": "1234567890", + "name": "Ekerö to Slagsta", + "from": "Ekerö", + "to": "Slagsta", + "time": None, + "weekday": ["mon", "fri"], + } + assert len(mock_setup_entry.mock_calls) == 1 + assert result2["result"].unique_id == "eker\u00f6-slagsta-None-['mon', 'fri']" + + @pytest.mark.parametrize( ("side_effect", "base_error"), [ diff --git a/tests/components/vicare/conftest.py b/tests/components/vicare/conftest.py index 3123693289a07e..af731b2ea035a7 100644 --- a/tests/components/vicare/conftest.py +++ b/tests/components/vicare/conftest.py @@ -2,6 +2,7 @@ from collections.abc import AsyncGenerator, Generator from dataclasses import dataclass +import re import time from unittest.mock import AsyncMock, Mock, patch @@ -32,47 +33,48 @@ class Fixture: data_file: str # Opt-in shared gateway serial; defaults to a per-fixture gateway when unset. gateway_id: str | None = None + online: bool = True class MockPyViCare: """Mocked PyVicare class based on a json dump.""" def __init__(self, fixtures: list[Fixture]) -> None: - """Init a single device from json dump.""" + """Init devices from json dumps, sharing one service per gateway.""" self.devices = [] + self.services: dict[str, MockViCareService] = {} for idx, fixture in enumerate(fixtures): - accessor = ViCareDeviceAccessor( - f"installation{idx}", - fixture.gateway_id or f"gateway{idx}", - f"deviceId{idx}", + gateway_id = fixture.gateway_id or f"gateway{idx}" + device_id = f"deviceId{idx}" + service = self.services.setdefault( + gateway_id, MockViCareService(fixture.roles) ) - service = MockViCareService(fixture) + service.add_device(device_id, fixture) self.devices.append( PyViCareDeviceConfig( - accessor, + ViCareDeviceAccessor(f"installation{idx}", gateway_id, device_id), service, "Vitovalor" if fixture.data_file.endswith("VitoValor.json") else f"model{idx}", - "Online", + "Online" if fixture.online else "Offline", roles=list(fixture.roles), ) ) # Simulate a device with an unsupported deviceType that PyViCare's # `devices` filter would drop but should still appear in `all_devices` # (used by diagnostics). - unsupported_accessor = ViCareDeviceAccessor( - "installation_unsupported", - "gateway_unsupported", - "deviceId_unsupported", - ) - unsupported_service = MockViCareService( - Fixture(set(), "vicare/dummy-device-no-serial.json") - ) + unsupported_fixture = Fixture(set(), "vicare/dummy-device-no-serial.json") + unsupported_service = MockViCareService(set()) + unsupported_service.add_device("deviceId_unsupported", unsupported_fixture) self.all_devices = [ *self.devices, PyViCareDeviceConfig( - unsupported_accessor, + ViCareDeviceAccessor( + "installation_unsupported", + "gateway_unsupported", + "deviceId_unsupported", + ), unsupported_service, "unsupported_model", "Online", @@ -92,25 +94,44 @@ def as_vicare_data(self) -> ViCareData: class MockViCareService: - """PyVicareService mock using a json dump.""" - - def __init__(self, fixture: Fixture) -> None: - """Initialize the mock from a json dump.""" - self._test_data = load_json_object_fixture(fixture.data_file) - # Mirror the real signature: fetch_all_features() requires an accessor, - # and no real service carries one. - self.fetch_all_features = Mock(side_effect=lambda accessor: self._test_data) + """Mock of the gateway-wide service PyViCare shares in viaGateway mode. + + One instance serves every device on the gateway: `fetch_all_features` + returns the bulk payload for all of them, and `getProperty` filters by + `accessor.device_id`, like `ViCareCachedServiceViaGateway` does. + """ + + def __init__(self, roles: set[str]) -> None: + """Initialize an empty gateway service.""" + self._features: dict[str, list] = {} + self.fetch_all_features = Mock(side_effect=self._fetch_all_features) self.setProperty = Mock() self.clear_cache = Mock() - self.roles = fixture.roles + self.roles = roles + + def add_device(self, device_id: str, fixture: Fixture) -> None: + """Add a device's features to the gateway payload.""" + features = load_json_object_fixture(fixture.data_file)["data"] + # In the real bulk payload every feature carries its own device in the + # uri, which is what consumers filter on. The fixtures all say device 0. + for feature in features: + if "uri" in feature: + feature["uri"] = re.sub( + r"/devices/[^/]+/", f"/devices/{device_id}/", feature["uri"] + ) + self._features[device_id] = features + + def _fetch_all_features(self, accessor: ViCareDeviceAccessor): + """Return the features of every device on the gateway.""" + return {"data": [f for features in self._features.values() for f in features]} def hasRoles(self, requested_roles: list[str]) -> bool: """Return true if requested roles are assigned.""" return requested_roles and set(requested_roles).issubset(self.roles) def getProperty(self, accessor: ViCareDeviceAccessor, property_name: str): - """Read a property from json dump.""" - return readFeature(self._test_data["data"], property_name) + """Read a property of one device from the gateway payload.""" + return readFeature(self._features[accessor.device_id], property_name) @pytest.fixture(autouse=True) diff --git a/tests/components/vicare/snapshots/test_diagnostics.ambr b/tests/components/vicare/snapshots/test_diagnostics.ambr index 4f189f5bc56be0..7aadd45e009f1f 100644 --- a/tests/components/vicare/snapshots/test_diagnostics.ambr +++ b/tests/components/vicare/snapshots/test_diagnostics.ambr @@ -20,7 +20,7 @@ }), }), 'timestamp': '2024-07-30T20:03:40.073Z', - 'uri': 'https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.serial', + 'uri': 'https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/deviceId0/features/device.serial', }), dict({ 'apiVersion': 1, @@ -36,7 +36,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.707Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level.total', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.buffer.charging.level.total', }), dict({ 'apiVersion': 1, @@ -56,7 +56,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.buffer.charging.level', }), dict({ 'apiVersion': 1, @@ -72,7 +72,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.713Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.pumps.circuit', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.solar.pumps.circuit', }), dict({ 'apiVersion': 1, @@ -98,7 +98,7 @@ }), }), 'timestamp': '2021-08-25T14:23:17.238Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burners.0.statistics', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.burners.0.statistics', }), dict({ 'apiVersion': 1, @@ -114,7 +114,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.971Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heating', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.operating.modes.heating', }), dict({ 'apiVersion': 1, @@ -130,7 +130,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/device', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/device', }), dict({ 'apiVersion': 1, @@ -146,7 +146,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.694Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.dhw.pumps.circulation.schedule', }), dict({ 'apiVersion': 1, @@ -166,7 +166,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.639Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.circulation.pump', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.circulation.pump', }), dict({ 'apiVersion': 1, @@ -183,7 +183,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.circulation', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.circulation', }), dict({ 'apiVersion': 1, @@ -199,7 +199,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.922Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.heating.schedule', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.heating.schedule', }), dict({ 'apiVersion': 1, @@ -215,7 +215,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.572Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.supply', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.sensors.temperature.supply', }), dict({ 'apiVersion': 1, @@ -231,7 +231,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.700Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.sensors.temperature.collector', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.solar.sensors.temperature.collector', }), dict({ 'apiVersion': 1, @@ -247,7 +247,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.677Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.active', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.operating.modes.active', }), dict({ 'apiVersion': 1, @@ -267,7 +267,7 @@ }), }), 'timestamp': '2021-08-25T14:16:46.543Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burner', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.burner', }), dict({ 'apiVersion': 1, @@ -283,7 +283,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.714Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.operating.programs.holiday', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.operating.programs.holiday', }), dict({ 'apiVersion': 1, @@ -299,7 +299,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.711Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level.bottom', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.buffer.charging.level.bottom', }), dict({ 'apiVersion': 1, @@ -328,7 +328,7 @@ }), }), 'timestamp': '2021-08-25T15:13:19.679Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.supply', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.sensors.temperature.supply', }), dict({ 'apiVersion': 1, @@ -344,7 +344,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.955Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhw', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.operating.modes.dhw', }), dict({ 'apiVersion': 1, @@ -384,7 +384,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.654Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.operating.modes.active', }), dict({ 'apiVersion': 1, @@ -452,7 +452,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.825Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.operating.programs.comfort', }), dict({ 'apiVersion': 1, @@ -469,7 +469,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.717Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/ventilation', }), dict({ 'apiVersion': 1, @@ -520,7 +520,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.909Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating.curve', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.heating.curve', }), dict({ 'apiVersion': 1, @@ -536,7 +536,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.838Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.sensors.temperature.commonSupply', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.boiler.sensors.temperature.commonSupply', }), dict({ 'apiVersion': 1, @@ -553,7 +553,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.circulation', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.circulation', }), dict({ 'apiVersion': 1, @@ -569,7 +569,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.903Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.frostprotection', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.frostprotection', }), dict({ 'apiVersion': 1, @@ -591,7 +591,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.863Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2', }), dict({ 'apiVersion': 1, @@ -609,7 +609,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.698Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.solar', }), dict({ 'apiVersion': 1, @@ -627,7 +627,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/ventilation.operating', }), dict({ 'apiVersion': 1, @@ -649,7 +649,7 @@ }), }), 'timestamp': '2021-08-25T14:16:46.550Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burners.0', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.burners.0', }), dict({ 'apiVersion': 1, @@ -667,7 +667,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.operating', }), dict({ 'apiVersion': 1, @@ -683,7 +683,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.560Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.standby', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.operating.programs.standby', }), dict({ 'apiVersion': 1, @@ -755,7 +755,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.541Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.holiday', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.operating.programs.holiday', }), dict({ 'apiVersion': 1, @@ -771,7 +771,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.726Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes.standby', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/ventilation.operating.modes.standby', }), dict({ 'apiVersion': 1, @@ -792,7 +792,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.operating.modes', }), dict({ 'apiVersion': 1, @@ -812,7 +812,7 @@ }), }), 'timestamp': '2021-08-25T14:18:44.841Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.pumps.primary', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.dhw.pumps.primary', }), dict({ 'apiVersion': 1, @@ -828,7 +828,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.722Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.programs.holiday', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/ventilation.operating.programs.holiday', }), dict({ 'apiVersion': 1, @@ -929,7 +929,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.920Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.heating.schedule', }), dict({ 'apiVersion': 1, @@ -945,7 +945,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.967Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhwAndHeating', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.operating.modes.dhwAndHeating', }), dict({ 'apiVersion': 1, @@ -990,7 +990,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.553Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.operating.programs.reduced', }), dict({ 'apiVersion': 1, @@ -1007,7 +1007,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.device.time', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.device.time', }), dict({ 'apiVersion': 1, @@ -1025,7 +1025,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.heating', }), dict({ 'apiVersion': 1, @@ -1097,7 +1097,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.543Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.holiday', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.operating.programs.holiday', }), dict({ 'apiVersion': 1, @@ -1137,7 +1137,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.666Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.operating.modes.active', }), dict({ 'apiVersion': 1, @@ -1238,7 +1238,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.918Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.heating.schedule', }), dict({ 'apiVersion': 1, @@ -1258,7 +1258,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.574Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.controller.serial', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.controller.serial', }), dict({ 'apiVersion': 1, @@ -1283,7 +1283,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.536Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.external', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.operating.programs.external', }), dict({ 'apiVersion': 1, @@ -1332,7 +1332,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.859Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0', }), dict({ 'apiVersion': 1, @@ -1352,7 +1352,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.939Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhw', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.operating.modes.dhw', }), dict({ 'apiVersion': 1, @@ -1369,7 +1369,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.pumps.circulation', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.dhw.pumps.circulation', }), dict({ 'apiVersion': 1, @@ -1393,7 +1393,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.operating.programs', }), dict({ 'apiVersion': 1, @@ -1411,7 +1411,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.sensors.temperature', }), dict({ 'apiVersion': 1, @@ -1431,7 +1431,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.894Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.frostprotection', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.frostprotection', }), dict({ 'apiVersion': 1, @@ -1451,7 +1451,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.958Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhwAndHeating', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.operating.modes.dhwAndHeating', }), dict({ 'apiVersion': 1, @@ -1468,7 +1468,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.operating', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.operating', }), dict({ 'apiVersion': 1, @@ -1495,7 +1495,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating', }), dict({ 'apiVersion': 1, @@ -1512,7 +1512,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burners', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.burners', }), dict({ 'apiVersion': 1, @@ -1529,7 +1529,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw.pumps.circulation', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.dhw.pumps.circulation', }), dict({ 'apiVersion': 1, @@ -1546,7 +1546,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.pumps', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.solar.pumps', }), dict({ 'apiVersion': 1, @@ -1562,7 +1562,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.708Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level.top', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.buffer.charging.level.top', }), dict({ 'apiVersion': 1, @@ -1579,7 +1579,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.sensors', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.solar.sensors', }), dict({ 'apiVersion': 1, @@ -1598,7 +1598,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.boiler', }), dict({ 'apiVersion': 1, @@ -1614,7 +1614,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.545Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.holiday', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.operating.programs.holiday', }), dict({ 'apiVersion': 1, @@ -1643,7 +1643,7 @@ }), }), 'timestamp': '2021-08-25T15:07:33.251Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.sensors.temperature.outside', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.sensors.temperature.outside', }), dict({ 'apiVersion': 1, @@ -1659,7 +1659,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.566Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.room', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.sensors.temperature.room', }), dict({ 'apiVersion': 1, @@ -1677,7 +1677,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.operating', }), dict({ 'apiVersion': 1, @@ -1810,7 +1810,7 @@ }), }), 'timestamp': '2021-08-25T15:13:35.950Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.power.consumption.total', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.power.consumption.total', }), dict({ 'apiVersion': 1, @@ -1828,7 +1828,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.dhw', }), dict({ 'apiVersion': 1, @@ -1844,7 +1844,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.724Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes.active', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/ventilation.operating.modes.active', }), dict({ 'apiVersion': 1, @@ -1893,7 +1893,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.861Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1', }), dict({ 'apiVersion': 1, @@ -2026,7 +2026,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.627Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.gas.consumption.heating', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.gas.consumption.heating', }), dict({ 'apiVersion': 1, @@ -2042,7 +2042,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.556Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reduced', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.operating.programs.reduced', }), dict({ 'apiVersion': 1, @@ -2143,7 +2143,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.866Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.pumps.circulation.schedule', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.dhw.pumps.circulation.schedule', }), dict({ 'apiVersion': 1, @@ -2159,7 +2159,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.719Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.programs.standard', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/ventilation.operating.programs.standard', }), dict({ 'apiVersion': 1, @@ -2176,7 +2176,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.pumps.circulation', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.dhw.pumps.circulation', }), dict({ 'apiVersion': 1, @@ -2307,7 +2307,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.883Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.schedule', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.dhw.schedule', }), dict({ 'apiVersion': 1, @@ -2324,7 +2324,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw.pumps', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.dhw.pumps', }), dict({ 'apiVersion': 1, @@ -2340,7 +2340,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.540Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.external', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.operating.programs.external', }), dict({ 'apiVersion': 1, @@ -2357,7 +2357,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.configuration', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.configuration', }), dict({ 'apiVersion': 1, @@ -2375,7 +2375,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.dhw', }), dict({ 'apiVersion': 1, @@ -2391,7 +2391,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.720Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.programs.eco', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/ventilation.operating.programs.eco', }), dict({ 'apiVersion': 1, @@ -2416,7 +2416,7 @@ }), }), 'timestamp': '2021-08-25T14:16:46.376Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.temperature', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.boiler.temperature', }), dict({ 'apiVersion': 1, @@ -2436,7 +2436,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.840Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.serial', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.boiler.serial', }), dict({ 'apiVersion': 1, @@ -2454,7 +2454,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.heating', }), dict({ 'apiVersion': 1, @@ -2475,7 +2475,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.609Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.pumps.circulation', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.dhw.pumps.circulation', }), dict({ 'apiVersion': 1, @@ -2495,7 +2495,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.693Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.configuration.multiFamilyHouse', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.configuration.multiFamilyHouse', }), dict({ 'apiVersion': 1, @@ -2519,7 +2519,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.operating.programs', }), dict({ 'apiVersion': 1, @@ -2537,7 +2537,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.operating', }), dict({ 'apiVersion': 1, @@ -2553,7 +2553,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.533Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.standby', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.operating.modes.standby', }), dict({ 'apiVersion': 1, @@ -2573,7 +2573,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.558Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.standby', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.operating.programs.standby', }), dict({ 'apiVersion': 1, @@ -2589,7 +2589,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.729Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes.ventilation', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/ventilation.operating.modes.ventilation', }), dict({ 'apiVersion': 1, @@ -2607,7 +2607,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.heating', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.heating', }), dict({ 'apiVersion': 1, @@ -2623,7 +2623,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.876Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw.pumps.circulation.schedule', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.dhw.pumps.circulation.schedule', }), dict({ 'apiVersion': 1, @@ -2668,7 +2668,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.548Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normal', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.operating.programs.normal', }), dict({ 'apiVersion': 1, @@ -2713,7 +2713,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.546Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normal', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.operating.programs.normal', }), dict({ 'apiVersion': 1, @@ -2733,7 +2733,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.963Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhwAndHeating', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.operating.modes.dhwAndHeating', }), dict({ 'apiVersion': 1, @@ -2749,7 +2749,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.649Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.active', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.operating.programs.active', }), dict({ 'apiVersion': 1, @@ -2769,7 +2769,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.933Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhw', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.operating.modes.dhw', }), dict({ 'apiVersion': 1, @@ -2785,7 +2785,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.890Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw.schedule', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.dhw.schedule', }), dict({ 'apiVersion': 1, @@ -2853,7 +2853,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.827Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.operating.programs.comfort', }), dict({ 'apiVersion': 1, @@ -2873,7 +2873,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.559Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.standby', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.operating.programs.standby', }), dict({ 'apiVersion': 1, @@ -2924,7 +2924,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.906Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating.curve', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.heating.curve', }), dict({ 'apiVersion': 1, @@ -2940,7 +2940,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.552Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.eco', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.operating.programs.eco', }), dict({ 'apiVersion': 1, @@ -3073,7 +3073,7 @@ }), }), 'timestamp': '2021-08-25T14:16:41.758Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.gas.consumption.dhw', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.gas.consumption.dhw', }), dict({ 'apiVersion': 1, @@ -3090,7 +3090,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.sensors', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.sensors', }), dict({ 'apiVersion': 1, @@ -3116,7 +3116,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.864Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits', }), dict({ 'apiVersion': 1, @@ -3136,7 +3136,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.643Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.active', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.operating.programs.active', }), dict({ 'apiVersion': 1, @@ -3152,7 +3152,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.634Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.power.production', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.solar.power.production', }), dict({ 'apiVersion': 1, @@ -3169,7 +3169,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.sensors', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.sensors', }), dict({ 'apiVersion': 1, @@ -3208,7 +3208,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.547Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.operating.programs.eco', }), dict({ 'apiVersion': 1, @@ -3224,7 +3224,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.551Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normal', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.operating.programs.normal', }), dict({ 'apiVersion': 1, @@ -3253,7 +3253,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.650Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.dhw', }), dict({ 'apiVersion': 1, @@ -3269,7 +3269,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.642Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.circulation.pump', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.circulation.pump', }), dict({ 'apiVersion': 1, @@ -3298,7 +3298,7 @@ }), }), 'timestamp': '2021-08-25T15:13:19.598Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.sensors.temperature.main', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.boiler.sensors.temperature.main', }), dict({ 'apiVersion': 1, @@ -3318,7 +3318,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.641Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.circulation.pump', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.circulation.pump', }), dict({ 'apiVersion': 1, @@ -3357,7 +3357,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.549Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.eco', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.operating.programs.eco', }), dict({ 'apiVersion': 1, @@ -3393,7 +3393,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.603Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.charging.level', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.dhw.charging.level', }), dict({ 'apiVersion': 1, @@ -3410,7 +3410,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.circulation', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.circulation', }), dict({ 'apiVersion': 1, @@ -3426,7 +3426,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.728Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes.standard', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/ventilation.operating.modes.standard', }), dict({ 'apiVersion': 1, @@ -3443,7 +3443,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.operating.programs', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.operating.programs', }), dict({ 'apiVersion': 1, @@ -3544,7 +3544,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.880Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.schedule', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.dhw.schedule', }), dict({ 'apiVersion': 1, @@ -3563,7 +3563,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.programs', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/ventilation.operating.programs', }), dict({ 'apiVersion': 1, @@ -3664,7 +3664,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.871Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.pumps.circulation.schedule', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.dhw.pumps.circulation.schedule', }), dict({ 'apiVersion': 1, @@ -3682,7 +3682,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.sensors.temperature', }), dict({ 'apiVersion': 1, @@ -3698,7 +3698,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.710Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level.middle', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.buffer.charging.level.middle', }), dict({ 'apiVersion': 1, @@ -3718,7 +3718,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.508Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.standby', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.operating.modes.standby', }), dict({ 'apiVersion': 1, @@ -3757,7 +3757,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.819Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.temperature.main', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.dhw.temperature.main', }), dict({ 'apiVersion': 1, @@ -3791,7 +3791,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.607Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.dhw.oneTimeCharge', }), dict({ 'apiVersion': 1, @@ -3924,7 +3924,7 @@ }), }), 'timestamp': '2021-08-25T14:16:41.785Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.gas.consumption.total', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.gas.consumption.total', }), dict({ 'apiVersion': 1, @@ -3941,7 +3941,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.sensors', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.sensors', }), dict({ 'apiVersion': 1, @@ -3966,7 +3966,7 @@ }), }), 'timestamp': '2021-08-25T14:16:46.499Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burners.0.modulation', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.burners.0.modulation', }), dict({ 'apiVersion': 1, @@ -3983,7 +3983,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.power.consumption', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.power.consumption', }), dict({ 'apiVersion': 1, @@ -4028,7 +4028,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.555Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reduced', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.operating.programs.reduced', }), dict({ 'apiVersion': 1, @@ -4045,7 +4045,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.sensors.temperature', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.sensors.temperature', }), dict({ 'apiVersion': 1, @@ -4061,7 +4061,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.564Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.room', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.sensors.temperature.room', }), dict({ 'apiVersion': 1, @@ -4077,7 +4077,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.sensors', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.boiler.sensors', }), dict({ 'apiVersion': 1, @@ -4095,7 +4095,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.sensors.temperature', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.solar.sensors.temperature', }), dict({ 'apiVersion': 1, @@ -4116,7 +4116,7 @@ }), }), 'timestamp': '2021-08-25T14:16:41.453Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.charging', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.dhw.charging', }), dict({ 'apiVersion': 1, @@ -4136,7 +4136,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.524Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.standby', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.operating.modes.standby', }), dict({ 'apiVersion': 1, @@ -4153,7 +4153,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.buffer', }), dict({ 'apiVersion': 1, @@ -4170,7 +4170,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.temperature', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.dhw.temperature', }), dict({ 'apiVersion': 1, @@ -4190,7 +4190,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.645Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.active', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.operating.programs.active', }), dict({ 'apiVersion': 1, @@ -4206,7 +4206,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.695Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.schedule', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.dhw.schedule', }), dict({ 'apiVersion': 1, @@ -4223,7 +4223,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.buffer.charging', }), dict({ 'apiVersion': 1, @@ -4239,7 +4239,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.830Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfort', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.operating.programs.comfort', }), dict({ 'apiVersion': 1, @@ -4260,7 +4260,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.operating.modes', }), dict({ 'apiVersion': 1, @@ -4280,7 +4280,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/ventilation.operating.modes', }), dict({ 'apiVersion': 1, @@ -4297,7 +4297,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.pumps', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.dhw.pumps', }), dict({ 'apiVersion': 1, @@ -4321,7 +4321,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.operating.programs', }), dict({ 'apiVersion': 1, @@ -4337,7 +4337,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.978Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heating', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.operating.modes.heating', }), dict({ 'apiVersion': 1, @@ -4355,7 +4355,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.sensors.temperature', }), dict({ 'apiVersion': 1, @@ -4371,7 +4371,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.sensors', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.dhw.sensors', }), dict({ 'apiVersion': 1, @@ -4395,7 +4395,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.637Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.outlet', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.dhw.sensors.temperature.outlet', }), dict({ 'apiVersion': 1, @@ -4412,7 +4412,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.device', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.device', }), dict({ 'apiVersion': 1, @@ -4429,7 +4429,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.sensors', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.sensors', }), dict({ 'apiVersion': 1, @@ -4450,7 +4450,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.575Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.device.time.offset', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.device.time.offset', }), dict({ 'apiVersion': 1, @@ -4466,7 +4466,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.562Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.room', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.sensors.temperature.room', }), dict({ 'apiVersion': 1, @@ -4483,7 +4483,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.pumps', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.dhw.pumps', }), dict({ 'apiVersion': 1, @@ -4503,7 +4503,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.900Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.frostprotection', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.frostprotection', }), dict({ 'apiVersion': 1, @@ -4519,7 +4519,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:47.633Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.sensors.temperature.dhw', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.solar.sensors.temperature.dhw', }), dict({ 'apiVersion': 1, @@ -4537,7 +4537,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.0.dhw', }), dict({ 'apiVersion': 1, @@ -4588,7 +4588,7 @@ }), }), 'timestamp': '2021-08-25T03:29:46.910Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.heating.curve', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.2.heating.curve', }), dict({ 'apiVersion': 1, @@ -4604,7 +4604,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.975Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heating', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.operating.modes.heating', }), dict({ 'apiVersion': 1, @@ -4629,7 +4629,7 @@ }), }), 'timestamp': '2021-08-25T03:29:47.538Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.external', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.operating.programs.external', }), dict({ 'apiVersion': 1, @@ -4658,7 +4658,7 @@ }), }), 'timestamp': '2021-08-25T15:02:49.557Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.dhw.sensors.temperature.hotWaterStorage', }), dict({ 'apiVersion': 1, @@ -4687,7 +4687,7 @@ }), }), 'timestamp': '2021-08-25T11:03:00.515Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.supply', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.sensors.temperature.supply', }), dict({ 'apiVersion': 1, @@ -4708,7 +4708,7 @@ 'properties': dict({ }), 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/deviceId0/features/heating.circuits.1.operating.modes', }), ]), 'device': dict({ diff --git a/tests/components/vicare/test_diagnostics.py b/tests/components/vicare/test_diagnostics.py index 7c25fa8a8bdacc..3dc22ac2ad9c76 100644 --- a/tests/components/vicare/test_diagnostics.py +++ b/tests/components/vicare/test_diagnostics.py @@ -1,6 +1,6 @@ """Test ViCare diagnostics.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from PyViCare.PyViCareUtils import PyViCareDeviceCommunicationError from syrupy.assertion import SnapshotAssertion @@ -8,6 +8,10 @@ from homeassistant.core import HomeAssistant +from . import MODULE, setup_integration +from .conftest import Fixture, MockPyViCare + +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -51,3 +55,36 @@ async def test_diagnostics_with_offline_device( assert "error" in error_entry assert "GATEWAY_OFFLINE" in error_entry["error"] assert error_entry["device"]["id"] == devices[0].device_id + + +async def test_diagnostics_scopes_features_to_their_device( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Devices sharing a gateway must not each dump the whole gateway payload.""" + fixtures: list[Fixture] = [ + Fixture({"type:boiler"}, "vicare/Vitodens300W.json", gateway_id="gateway0"), + Fixture({"type:heatpump"}, "vicare/Vitocal250A.json", gateway_id="gateway0"), + ] + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + ), + patch( + f"{MODULE}._setup_vicare_api", + return_value=MockPyViCare(fixtures).as_vicare_data(), + ), + ): + await setup_integration(hass, mock_config_entry) + + diag = await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + + dumps = {entry["device"]["id"]: entry["data"] for entry in diag["data"]} + # 167 and 325 features; without scoping both would dump all 492. + assert [len(dumps["deviceId0"]), len(dumps["deviceId1"])] == [167, 325] + for device_id in ("deviceId0", "deviceId1"): + assert { + feature["uri"].split("/devices/")[1].split("/")[0] + for feature in dumps[device_id] + } == {device_id} diff --git a/tests/components/vicare/test_init.py b/tests/components/vicare/test_init.py index 10f370691e50f7..7a1f4a7d9d154b 100644 --- a/tests/components/vicare/test_init.py +++ b/tests/components/vicare/test_init.py @@ -1,7 +1,7 @@ """Test ViCare initialization and migration.""" from datetime import timedelta -from unittest.mock import Mock, patch +from unittest.mock import Mock, call, patch from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory @@ -11,9 +11,10 @@ PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError, PyViCareInvalidDataError, + PyViCareNotSupportedFeatureError, ) -from homeassistant.components.vicare.const import DOMAIN +from homeassistant.components.vicare.const import DEFAULT_CACHE_DURATION, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( CONF_CLIENT_ID, @@ -542,12 +543,12 @@ async def test_coordinator_handles_invalid_data( assert "Unexpected error fetching" not in caplog.text -async def test_per_device_failure_isolation( +async def test_per_gateway_failure_isolation( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_config_entry: MockConfigEntry, ) -> None: - """A transient failure on one device must not affect the other device's sensors.""" + """A transient failure on one gateway must not affect another gateway's sensors.""" fixtures: list[Fixture] = [ Fixture({"type:climateSensor"}, "vicare/RoomSensor1.json"), Fixture({"type:climateSensor"}, "vicare/RoomSensor2.json"), @@ -586,7 +587,7 @@ async def test_per_device_failure_isolation( } ) - # Coordinator interval scales by device count (60 * 2 = 120s); tick past it. + # Coordinator interval scales by gateway count (60 * 2 = 120s); tick past it. freezer.tick(timedelta(seconds=300)) async_fire_time_changed(hass, fire_all=True) await hass.async_block_till_done(wait_background_tasks=True) @@ -595,6 +596,56 @@ async def test_per_device_failure_isolation( assert hass.states.get(sensor_device1).state != STATE_UNAVAILABLE +async def test_devices_on_same_gateway_share_coordinator( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, +) -> None: + """Two devices behind one gateway share one coordinator and one fetch.""" + fixtures: list[Fixture] = [ + Fixture({"type:climateSensor"}, "vicare/RoomSensor1.json", gateway_id="gwA"), + Fixture({"type:climateSensor"}, "vicare/RoomSensor2.json", gateway_id="gwA"), + ] + mock_vicare = MockPyViCare(fixtures) + service0 = mock_vicare.devices[0].service + + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + ), + patch( + f"{MODULE}._setup_vicare_api", + return_value=mock_vicare.as_vicare_data(), + ), + patch(f"{MODULE}.PLATFORMS", [Platform.SENSOR]), + ): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + sensor_device0 = "sensor.model0_temperature" + sensor_device1 = "sensor.model1_temperature" + assert hass.states.get(sensor_device0).state != STATE_UNAVAILABLE + assert hass.states.get(sensor_device1).state != STATE_UNAVAILABLE + + # The gateway's single fetch failing takes every device on it offline. + service0.fetch_all_features.side_effect = PyViCareInternalServerError( + { + "statusCode": 500, + "errorType": "INTERNAL_SERVER_ERROR", + "message": "Internal Server Error", + "viErrorId": "0", + } + ) + # One gateway -> interval 60s; tick past it. + freezer.tick(timedelta(seconds=120)) + async_fire_time_changed(hass, fire_all=True) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(sensor_device0).state == STATE_UNAVAILABLE + assert hass.states.get(sensor_device1).state == STATE_UNAVAILABLE + + async def test_coordinator_auth_failure_triggers_reauth( hass: HomeAssistant, freezer: FrozenDateTimeFactory, @@ -698,3 +749,127 @@ async def test_device_via_device_missing_gateway( ) assert channel_device is not None assert channel_device.via_device_id is None + + +async def test_setup_runs_pyvicare_init_and_fetches_once_per_gateway( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Set up through _setup_vicare_api instead of a prebuilt ViCareData. + + Covers the via-gateway init, the gateway-based cache duration, and one + fetch per gateway. + """ + # Two devices behind gwA, one behind gwB: two gateways, three devices. + fixtures: list[Fixture] = [ + Fixture({"type:climateSensor"}, "vicare/RoomSensor1.json", gateway_id="gwA"), + Fixture({"type:climateSensor"}, "vicare/RoomSensor2.json", gateway_id="gwA"), + Fixture({"type:climateSensor"}, "vicare/RoomSensor1.json", gateway_id="gwB"), + ] + client = MockPyViCare(fixtures) + # viaGateway has to be set before init, the services are wired during init. + setup_calls: list[str] = [] + client.loadViaGateway = Mock(side_effect=lambda _: setup_calls.append("gateway")) + client.setCacheDuration = Mock() + client.initWithExternalOAuth = Mock( + side_effect=lambda _: setup_calls.append("init") + ) + + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + ), + patch(f"{MODULE}.PyViCare", return_value=client), + patch(f"{MODULE}.PLATFORMS", [Platform.SENSOR]), + ): + mock_config_entry.add_to_hass(hass) + 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 + + # viaGateway mode enabled, cache duration scaled to the gateway count. + client.loadViaGateway.assert_called_with(True) + # Setup re-inits once to apply the gateway-based cache duration. + assert setup_calls == ["gateway", "init", "gateway", "init"] + assert call(DEFAULT_CACHE_DURATION * 2) in client.setCacheDuration.call_args_list + + # One refresh per gateway, and the two devices behind gwA share that one + # service, so the second device is served without a fetch of its own. + assert client.services["gwA"].fetch_all_features.call_count == 1 + assert client.services["gwB"].fetch_all_features.call_count == 1 + assert hass.states.get("sensor.model0_temperature").state == "17.5" + assert hass.states.get("sensor.model1_temperature").state == "16.9" + + +async def test_offline_gateway_does_not_stretch_the_cache( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """An offline gateway is never fetched, so it must not size the cache.""" + fixtures: list[Fixture] = [ + Fixture({"type:climateSensor"}, "vicare/RoomSensor1.json", gateway_id="gwA"), + Fixture({"type:climateSensor"}, "vicare/RoomSensor2.json", gateway_id="gwB"), + Fixture( + {"type:climateSensor"}, + "vicare/RoomSensor1.json", + gateway_id="gwC", + online=False, + ), + ] + client = MockPyViCare(fixtures) + client.loadViaGateway = Mock() + client.setCacheDuration = Mock() + client.initWithExternalOAuth = Mock() + + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + ), + patch(f"{MODULE}.PyViCare", return_value=client), + patch(f"{MODULE}.PLATFORMS", [Platform.SENSOR]), + ): + mock_config_entry.add_to_hass(hass) + 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 + # Two online gateways out of three, so the cache matches the coordinator + # interval instead of being stretched to 3 x 60s. + assert call(DEFAULT_CACHE_DURATION * 2) in client.setCacheDuration.call_args_list + assert ( + call(DEFAULT_CACHE_DURATION * 3) not in client.setCacheDuration.call_args_list + ) + assert client.services["gwC"].fetch_all_features.call_count == 0 + + +async def test_setup_loads_with_unpaid_package_gateway( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """A gateway whose bulk fetch raises PACKAGE_NOT_PAID_FOR still loads.""" + fixtures: list[Fixture] = [ + Fixture({"type:climateSensor"}, "vicare/RoomSensor1.json") + ] + mock_vicare = MockPyViCare(fixtures) + mock_vicare.devices[ + 0 + ].service.fetch_all_features.side_effect = PyViCareNotSupportedFeatureError( + "PACKAGE_NOT_PAID_FOR" + ) + + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + ), + patch( + f"{MODULE}._setup_vicare_api", + return_value=mock_vicare.as_vicare_data(), + ), + patch(f"{MODULE}.PLATFORMS", [Platform.SENSOR]), + ): + mock_config_entry.add_to_hass(hass) + 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 diff --git a/tests/components/vicare/test_switch.py b/tests/components/vicare/test_switch.py index efdda6901e055f..24e3b6c80478a2 100644 --- a/tests/components/vicare/test_switch.py +++ b/tests/components/vicare/test_switch.py @@ -201,7 +201,8 @@ async def test_turn_on_refused_while_another_quickmode_runs( def activate_quickmode(mock_vicare: MockPyViCare, device: int, quickmode: str) -> None: """Mark a quickmode as active in the fixture data of a mocked device.""" - for feature in mock_vicare.devices[device].service._test_data["data"]: + config = mock_vicare.devices[device] + for feature in config.service._features[config.device_id]: if feature["feature"] == f"ventilation.quickmodes.{quickmode}": feature["properties"]["active"]["value"] = True return diff --git a/tests/components/vistapool/snapshots/test_number.ambr b/tests/components/vistapool/snapshots/test_number.ambr index f1dab3db6b315c..5d5bc3411a0abd 100644 --- a/tests/components/vistapool/snapshots/test_number.ambr +++ b/tests/components/vistapool/snapshots/test_number.ambr @@ -270,7 +270,7 @@ 'object_id_base': 'Redox setpoint', 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Redox setpoint', 'platform': 'vistapool', @@ -285,6 +285,7 @@ # name: test_all_entities[number.my_pool_redox_setpoint-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'voltage', : 'My Pool Redox setpoint', : 800, : 500, diff --git a/tests/components/vistapool/snapshots/test_sensor.ambr b/tests/components/vistapool/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..69d1d96c01a332 --- /dev/null +++ b/tests/components/vistapool/snapshots/test_sensor.ambr @@ -0,0 +1,500 @@ +# serializer version: 1 +# name: test_all_entities[sensor.my_pool_chlorine-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_pool_chlorine', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Chlorine', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Chlorine', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'chlorine', + 'unique_id': 'ABCDEF1234567890-chlorine', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.my_pool_chlorine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'My Pool Chlorine', + : , + }), + 'context': , + 'entity_id': 'sensor.my_pool_chlorine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.2', + }) +# --- +# name: test_all_entities[sensor.my_pool_conductivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_pool_conductivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Conductivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Conductivity', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'conductivity', + 'unique_id': 'ABCDEF1234567890-conductivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.my_pool_conductivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'My Pool Conductivity', + : , + }), + 'context': , + 'entity_id': 'sensor.my_pool_conductivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.5', + }) +# --- +# name: test_all_entities[sensor.my_pool_electrolysis-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_pool_electrolysis', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electrolysis', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Electrolysis', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electrolysis', + 'unique_id': 'ABCDEF1234567890-electrolysis', + 'unit_of_measurement': 'g/h', + }) +# --- +# name: test_all_entities[sensor.my_pool_electrolysis-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'My Pool Electrolysis', + : , + : 'g/h', + }), + 'context': , + 'entity_id': 'sensor.my_pool_electrolysis', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_all_entities[sensor.my_pool_filtration_intel_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_pool_filtration_intel_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filtration intel time', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filtration intel time', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filtration_intel_time', + 'unique_id': 'ABCDEF1234567890-filtration_intel_time', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_pool_filtration_intel_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'My Pool Filtration intel time', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.my_pool_filtration_intel_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.my_pool_ph-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_pool_ph', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABCDEF1234567890-ph', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.my_pool_ph-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'ph', + : 'My Pool pH', + : , + }), + 'context': , + 'entity_id': 'sensor.my_pool_ph', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.42', + }) +# --- +# name: test_all_entities[sensor.my_pool_redox_potential-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_pool_redox_potential', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Redox potential', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Redox potential', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'redox_potential', + 'unique_id': 'ABCDEF1234567890-redox_potential', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_pool_redox_potential-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'My Pool Redox potential', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.my_pool_redox_potential', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '707', + }) +# --- +# name: test_all_entities[sensor.my_pool_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_pool_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABCDEF1234567890-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_pool_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'My Pool Temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.my_pool_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.5', + }) +# --- +# name: test_all_entities[sensor.my_pool_uv-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_pool_uv', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'UV', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'UV', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uv', + 'unique_id': 'ABCDEF1234567890-uv', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.my_pool_uv-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'My Pool UV', + : , + }), + 'context': , + 'entity_id': 'sensor.my_pool_uv', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_all_entities[sensor.my_pool_wi_fi_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_pool_wi_fi_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi signal strength', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wi-Fi signal strength', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rssi', + 'unique_id': 'ABCDEF1234567890-rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[sensor.my_pool_wi_fi_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'signal_strength', + : 'My Pool Wi-Fi signal strength', + : , + : 'dBm', + }), + 'context': , + 'entity_id': 'sensor.my_pool_wi_fi_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-65', + }) +# --- diff --git a/tests/components/vistapool/snapshots/test_switch.ambr b/tests/components/vistapool/snapshots/test_switch.ambr index 225858dd63a33b..88a1b12640ae59 100644 --- a/tests/components/vistapool/snapshots/test_switch.ambr +++ b/tests/components/vistapool/snapshots/test_switch.ambr @@ -24,7 +24,7 @@ 'object_id_base': 'Electrolysis boost', 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Electrolysis boost', 'platform': 'vistapool', @@ -39,6 +39,7 @@ # name: test_all_entities[switch.my_pool_electrolysis_boost-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'switch', : 'My Pool Electrolysis boost', }), 'context': , @@ -74,7 +75,7 @@ 'object_id_base': 'Electrolysis cover', 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Electrolysis cover', 'platform': 'vistapool', @@ -89,6 +90,7 @@ # name: test_all_entities[switch.my_pool_electrolysis_cover-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'switch', : 'My Pool Electrolysis cover', }), 'context': , @@ -124,7 +126,7 @@ 'object_id_base': 'Filtration', 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Filtration', 'platform': 'vistapool', @@ -139,6 +141,7 @@ # name: test_all_entities[switch.my_pool_filtration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'switch', : 'My Pool Filtration', }), 'context': , @@ -174,7 +177,7 @@ 'object_id_base': 'Relay 1', 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Relay 1', 'platform': 'vistapool', @@ -189,6 +192,7 @@ # name: test_all_entities[switch.my_pool_relay_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'switch', : 'My Pool Relay 1', }), 'context': , @@ -224,7 +228,7 @@ 'object_id_base': 'Relay 2', 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Relay 2', 'platform': 'vistapool', @@ -239,6 +243,7 @@ # name: test_all_entities[switch.my_pool_relay_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'switch', : 'My Pool Relay 2', }), 'context': , @@ -274,7 +279,7 @@ 'object_id_base': 'Relay 3', 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Relay 3', 'platform': 'vistapool', @@ -289,6 +294,7 @@ # name: test_all_entities[switch.my_pool_relay_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'switch', : 'My Pool Relay 3', }), 'context': , @@ -324,7 +330,7 @@ 'object_id_base': 'Relay 4', 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Relay 4', 'platform': 'vistapool', @@ -339,6 +345,7 @@ # name: test_all_entities[switch.my_pool_relay_4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'switch', : 'My Pool Relay 4', }), 'context': , diff --git a/tests/components/vistapool/test_select.py b/tests/components/vistapool/test_select.py index c1872ca0f2d98c..853c660be7612b 100644 --- a/tests/components/vistapool/test_select.py +++ b/tests/components/vistapool/test_select.py @@ -1,6 +1,7 @@ """Tests for the Vistapool select platform.""" from collections.abc import Generator +from copy import deepcopy from typing import Any from unittest.mock import AsyncMock, patch @@ -13,12 +14,17 @@ DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.const import ( + ATTR_ENTITY_ID, + EVENT_STATE_CHANGED, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_capture_events, snapshot_platform @pytest.fixture(autouse=True) @@ -215,3 +221,304 @@ async def test_select_option_raises_on_api_error( blocking=True, ) assert excinfo.value.translation_key == "set_failed" + + +_LIGHT_SCHEDULE_DATA = { + "main": {"version": 1}, + "light": {"mode": 1, "status": 0, "freq": 86400, "from": 79200, "to": 3600}, +} + + +async def test_light_selects_not_created_without_scheduling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + mock_pool_data: dict[str, Any], +) -> None: + """Test controllers without light scheduling do not get the light selects.""" + mock_vistapool_client.fetch_pool_data.return_value = mock_pool_data + 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 hass.states.get("select.my_pool_light_mode") is None + assert hass.states.get("select.my_pool_light_schedule_frequency") is None + + +@pytest.mark.parametrize( + ("mode", "status", "expected"), + [ + pytest.param(1, 0, "auto", id="schedule_armed"), + pytest.param(1, 1, "auto", id="schedule_armed_while_on"), + pytest.param(0, 1, "on", id="manual_on"), + pytest.param(0, 0, "off", id="manual_off"), + ], +) +async def test_light_mode_current_option( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + mode: int, + status: int, + expected: str, +) -> None: + """Test the armed schedule wins over the on/off state.""" + data = deepcopy(_LIGHT_SCHEDULE_DATA) + data["light"]["mode"] = mode + data["light"]["status"] = status + mock_vistapool_client.fetch_pool_data.return_value = data + 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 hass.states.get("select.my_pool_light_mode").state == expected + + +@pytest.mark.parametrize( + ("option", "expected_updates"), + [ + pytest.param("off", {"light.mode": 0, "light.status": 0}, id="off"), + pytest.param("on", {"light.mode": 0, "light.status": 1}, id="on"), + pytest.param("auto", {"light.mode": 1}, id="auto"), + ], +) +async def test_light_mode_select_writes_one_command( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + option: str, + expected_updates: dict[str, int], +) -> None: + """Test each option lands as a single multi-field command. + + Writing the fields separately would leave the controller half-applied + between the two commands. + """ + mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LIGHT_SCHEDULE_DATA) + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.my_pool_light_mode", ATTR_OPTION: option}, + blocking=True, + ) + + mock_vistapool_client.set_values.assert_awaited_once_with( + "ABCDEF1234567890", expected_updates + ) + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + pytest.param(86400, "daily", id="daily"), + pytest.param(604800, "weekly", id="weekly"), + pytest.param(12345, None, id="unknown_value"), + ], +) +async def test_light_schedule_frequency_maps_raw_values( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + raw: int, + expected: str | None, +) -> None: + """Test the frequency maps by raw seconds, not by option index.""" + data = deepcopy(_LIGHT_SCHEDULE_DATA) + data["light"]["freq"] = raw + mock_vistapool_client.fetch_pool_data.return_value = data + 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() + + state = hass.states.get("select.my_pool_light_schedule_frequency").state + assert state == (expected or STATE_UNKNOWN) + + +async def test_light_schedule_frequency_writes_raw_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test selecting a frequency writes its raw seconds value.""" + mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LIGHT_SCHEDULE_DATA) + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.my_pool_light_schedule_frequency", + ATTR_OPTION: "weekly", + }, + blocking=True, + ) + + mock_vistapool_client.set_value.assert_awaited_once_with( + "ABCDEF1234567890", "light.freq", 604800 + ) + + +async def test_select_reflects_choice_before_push( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + mock_pool_data: dict[str, Any], +) -> None: + """Test a select shows the chosen option without waiting for the push.""" + mock_vistapool_client.fetch_pool_data.return_value = mock_pool_data + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.my_pool_pump_speed", ATTR_OPTION: "high"}, + blocking=True, + ) + + assert hass.states.get("select.my_pool_pump_speed").state == "high" + + +async def test_light_mode_reflects_choice_before_push( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test the light mode select applies every field of the chosen option.""" + mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LIGHT_SCHEDULE_DATA) + 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 hass.states.get("select.my_pool_light_mode").state == "auto" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.my_pool_light_mode", ATTR_OPTION: "on"}, + blocking=True, + ) + + # Reads back as on only if both light.mode and light.status were applied. + assert hass.states.get("select.my_pool_light_mode").state == "on" + + +async def test_light_schedule_frequency_reflects_choice_before_push( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test the frequency select shows the chosen option immediately.""" + mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LIGHT_SCHEDULE_DATA) + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.my_pool_light_schedule_frequency", + ATTR_OPTION: "weekly", + }, + blocking=True, + ) + + assert hass.states.get("select.my_pool_light_schedule_frequency").state == "weekly" + + +async def test_light_mode_never_publishes_partial_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test leaving auto does not briefly read as another option. + + light.mode and light.status both feed current_option, so applying them + one at a time would publish an off state between the two writes. + """ + mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LIGHT_SCHEDULE_DATA) + 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() + + events = async_capture_events(hass, EVENT_STATE_CHANGED) + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.my_pool_light_mode", ATTR_OPTION: "on"}, + blocking=True, + ) + await hass.async_block_till_done() + + states = [ + event.data["new_state"].state + for event in events + if event.data["entity_id"] == "select.my_pool_light_mode" + ] + assert states == ["on"] + + +async def test_light_mode_raises_on_api_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test a failed multi-field write raises and leaves the state alone.""" + mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LIGHT_SCHEDULE_DATA) + mock_vistapool_client.set_values.side_effect = AquariteError("boom") + 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 hass.states.get("select.my_pool_light_mode").state == "auto" + + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.my_pool_light_mode", ATTR_OPTION: "on"}, + blocking=True, + ) + assert excinfo.value.translation_key == "set_failed" + + # The write never reached the controller, so nothing may be applied. + assert hass.states.get("select.my_pool_light_mode").state == "auto" + + +async def test_light_schedule_frequency_created_for_zero_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test a reported zero still creates the entity. + + Zero is a value the controller reports, not a missing field, so it must + surface as an unknown option rather than silently dropping the entity. + """ + data = deepcopy(_LIGHT_SCHEDULE_DATA) + data["light"]["freq"] = 0 + mock_vistapool_client.fetch_pool_data.return_value = data + 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() + + state = hass.states.get("select.my_pool_light_schedule_frequency") + assert state is not None + assert state.state == STATE_UNKNOWN diff --git a/tests/components/vistapool/test_sensor.py b/tests/components/vistapool/test_sensor.py index df745fbd0030ce..206bdc7f78dc1a 100644 --- a/tests/components/vistapool/test_sensor.py +++ b/tests/components/vistapool/test_sensor.py @@ -2,12 +2,63 @@ from __future__ import annotations +from collections.abc import Generator from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture +def _only_sensor_platform() -> Generator[None]: + """Restrict integration setup to the sensor platform for the snapshot.""" + with patch("homeassistant.components.vistapool.PLATFORMS", [Platform.SENSOR]): + yield + + +@pytest.mark.usefixtures("_only_sensor_platform", "entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test sensor entities when every module-gated sensor is present.""" + mock_vistapool_client.fetch_pool_data.return_value = { + "main": { + "hasCD": 1, + "hasCL": 1, + "hasPH": 1, + "hasRX": 1, + "hasUV": 1, + "hasHidro": 1, + "RSSI": -65, + "temperature": 25.5, + "version": 1, + }, + "hidro": {"is_electrolysis": True, "current": 50}, + "modules": { + "ph": {"current": "742"}, + "rx": {"current": 707}, + "cl": {"current": "120"}, + "cd": {"current": "150"}, + "uv": {"current": "100"}, + }, + } + 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() -from tests.common import MockConfigEntry + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) async def test_sensors_default_modules( diff --git a/tests/components/vistapool/test_time.py b/tests/components/vistapool/test_time.py index 09ecbf0ff4b914..605f35e8cc2e23 100644 --- a/tests/components/vistapool/test_time.py +++ b/tests/components/vistapool/test_time.py @@ -1,6 +1,7 @@ """Tests for the Vistapool time platform.""" from collections.abc import Generator +from copy import deepcopy from typing import Any from unittest.mock import AsyncMock, patch @@ -177,3 +178,87 @@ async def test_time_set_value_raises_on_api_error( blocking=True, ) assert excinfo.value.translation_key == "set_failed" + + +_LIGHT_SCHEDULE_DATA = { + "main": {"version": 1}, + "light": {"mode": 1, "status": 0, "from": 79200, "to": 3600}, +} + + +async def test_light_schedule_times_not_created_without_scheduling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + mock_pool_data: dict[str, Any], +) -> None: + """Test controllers without light scheduling do not get the light times.""" + mock_vistapool_client.fetch_pool_data.return_value = mock_pool_data + 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 hass.states.get("time.my_pool_light_schedule_start") is None + assert hass.states.get("time.my_pool_light_schedule_end") is None + + +async def test_light_schedule_times_decode_seconds( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test the light schedule bounds decode from seconds since midnight.""" + mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LIGHT_SCHEDULE_DATA) + 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() + + # 79200 is 22:00, 3600 is 01:00 the next morning. + assert hass.states.get("time.my_pool_light_schedule_start").state == "22:00:00" + assert hass.states.get("time.my_pool_light_schedule_end").state == "01:00:00" + + +async def test_light_schedule_time_set_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test setting a light schedule bound writes seconds since midnight.""" + mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LIGHT_SCHEDULE_DATA) + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "time.my_pool_light_schedule_start", ATTR_TIME: "21:30:00"}, + blocking=True, + ) + + mock_vistapool_client.set_value.assert_awaited_once_with( + "ABCDEF1234567890", "light.from", 77400 + ) + + +async def test_light_schedule_time_created_for_midnight( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test a schedule bound of zero seconds still creates the entity. + + Zero is midnight, a legitimate schedule bound, not a missing field. + """ + data = deepcopy(_LIGHT_SCHEDULE_DATA) + data["light"]["from"] = 0 + mock_vistapool_client.fetch_pool_data.return_value = data + 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 hass.states.get("time.my_pool_light_schedule_start").state == "00:00:00"