diff --git a/CODEOWNERS b/CODEOWNERS index a82296722c26ff..a8a53f8272da99 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1075,8 +1075,8 @@ build.json @home-assistant/supervisor /tests/components/onewire/ @garbled1 @epenet /homeassistant/components/onkyo/ @arturpragacz @eclair4151 /tests/components/onkyo/ @arturpragacz @eclair4151 -/homeassistant/components/onvif/ @hunterjm -/tests/components/onvif/ @hunterjm +/homeassistant/components/onvif/ @hunterjm @jterrace +/tests/components/onvif/ @hunterjm @jterrace /homeassistant/components/open_meteo/ @frenck /tests/components/open_meteo/ @frenck /homeassistant/components/openai_conversation/ @balloob diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index b74600a2789c17..6ae6ebd728b894 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -138,6 +138,27 @@ }, "constitution": { "default": "mdi:run-fast" + }, + "food_total": { + "default": "mdi:candy", + "state": { + "0": "mdi:candy-off" + } + }, + "eggs_total": { + "default": "mdi:egg", + "state": { + "0": "mdi:egg-off" + } + }, + "hatching_potions_total": { + "default": "mdi:flask-round-bottom" + }, + "saddle": { + "default": "mdi:horse" + }, + "quest_scrolls": { + "default": "mdi:script-text-outline" } }, "switch": { diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index 2bcb534af42707..b42ffa68dc9a90 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -29,7 +29,7 @@ from .const import ASSETS_URL from .entity import HabiticaBase from .types import HabiticaConfigEntry -from .util import get_attribute_points, get_attributes_total +from .util import get_attribute_points, get_attributes_total, inventory_list _LOGGER = logging.getLogger(__name__) @@ -73,6 +73,11 @@ class HabiticaSensorEntity(StrEnum): INTELLIGENCE = "intelligence" CONSTITUTION = "constitution" PERCEPTION = "perception" + EGGS_TOTAL = "eggs_total" + HATCHING_POTIONS_TOTAL = "hatching_potions_total" + FOOD_TOTAL = "food_total" + SADDLE = "saddle" + QUEST_SCROLLS = "quest_scrolls" SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( @@ -179,6 +184,44 @@ class HabiticaSensorEntity(StrEnum): suggested_display_precision=0, native_unit_of_measurement="CON", ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.EGGS_TOTAL, + translation_key=HabiticaSensorEntity.EGGS_TOTAL, + value_fn=lambda user, _: sum(n for n in user.items.eggs.values()), + entity_picture="Pet_Egg_Egg.png", + attributes_fn=lambda user, content: inventory_list(user, content, "eggs"), + ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.HATCHING_POTIONS_TOTAL, + translation_key=HabiticaSensorEntity.HATCHING_POTIONS_TOTAL, + value_fn=lambda user, _: sum(n for n in user.items.hatchingPotions.values()), + entity_picture="Pet_HatchingPotion_RoyalPurple.png", + attributes_fn=( + lambda user, content: inventory_list(user, content, "hatchingPotions") + ), + ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.FOOD_TOTAL, + translation_key=HabiticaSensorEntity.FOOD_TOTAL, + value_fn=( + lambda user, _: sum(n for k, n in user.items.food.items() if k != "Saddle") + ), + entity_picture="Pet_Food_Strawberry.png", + attributes_fn=lambda user, content: inventory_list(user, content, "food"), + ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.SADDLE, + translation_key=HabiticaSensorEntity.SADDLE, + value_fn=lambda user, _: user.items.food.get("Saddle", 0), + entity_picture="Pet_Food_Saddle.png", + ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.QUEST_SCROLLS, + translation_key=HabiticaSensorEntity.QUEST_SCROLLS, + value_fn=(lambda user, _: sum(n for n in user.items.quests.values())), + entity_picture="inventory_quest_scroll_dustbunnies.png", + attributes_fn=lambda user, content: inventory_list(user, content, "quests"), + ), ) diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index b4925861d67444..fc6d6aee6879ca 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -310,6 +310,26 @@ "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::buffs::name%]" } } + }, + "eggs_total": { + "name": "Eggs", + "unit_of_measurement": "eggs" + }, + "hatching_potions_total": { + "name": "Hatching potions", + "unit_of_measurement": "potions" + }, + "food_total": { + "name": "Pet food", + "unit_of_measurement": "foods" + }, + "saddle": { + "name": "Saddles", + "unit_of_measurement": "saddles" + }, + "quest_scrolls": { + "name": "Quest scrolls", + "unit_of_measurement": "scrolls" } }, "switch": { diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 4c1e54639d077f..0a7c861eb7ee9e 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -159,3 +159,14 @@ def get_attributes_total(user: UserData, content: ContentData, attribute: str) - return floor( sum(value for value in get_attribute_points(user, content, attribute).values()) ) + + +def inventory_list( + user: UserData, content: ContentData, item_type: str +) -> dict[str, int]: + """List inventory items of given type.""" + return { + getattr(content, item_type)[k].text: v + for k, v in getattr(user.items, item_type, {}).items() + if k != "Saddle" + } diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py index a1d922c2d80d8b..0d299a2e93a91a 100644 --- a/homeassistant/components/lametric/number.py +++ b/homeassistant/components/lametric/number.py @@ -33,7 +33,6 @@ class LaMetricNumberEntityDescription(NumberEntityDescription): LaMetricNumberEntityDescription( key="brightness", translation_key="brightness", - name="Brightness", entity_category=EntityCategory.CONFIG, native_step=1, native_min_value=0, @@ -45,11 +44,11 @@ class LaMetricNumberEntityDescription(NumberEntityDescription): LaMetricNumberEntityDescription( key="volume", translation_key="volume", - name="Volume", entity_category=EntityCategory.CONFIG, native_step=1, native_min_value=0, native_max_value=100, + native_unit_of_measurement=PERCENTAGE, has_fn=lambda device: bool(device.audio and device.audio.available), value_fn=lambda device: device.audio.volume if device.audio else 0, set_value_fn=lambda api, volume: api.audio(volume=int(volume)), diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index 0fd6f5a12dc1ab..01e7823c76b5f4 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -66,6 +66,14 @@ "name": "Dismiss all notifications" } }, + "number": { + "brightness": { + "name": "Brightness" + }, + "volume": { + "name": "Volume" + } + }, "sensor": { "rssi": { "name": "Wi-Fi signal" diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index a31a285654417d..e6d0dfce2d4c43 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -27,7 +27,7 @@ }, "pubsub": { "title": "Configure Google Cloud Pub/Sub", - "description": "Home Assistant uses Cloud Pub/Sub receive realtime Nest device updates. Nest servers publish updates to a Pub/Sub topic and Home Assistant receives the updates through a Pub/Sub subscription.\n\n1. Visit the [Device Access Console]({device_access_console_url}) and ensure a Pub/Sub topic is configured.\n2. Visit the [Cloud Console]({url}) to find your Google Cloud Project ID and confirm it is correct below.\n3. The next step will attempt to auto-discover Pub/Sub topics and subscriptions.\n\nSee the integration documentation for [more info]({more_info_url}).", + "description": "Home Assistant uses Cloud Pub/Sub receive realtime Nest device updates. Nest servers publish updates to a Pub/Sub topic and Home Assistant receives the updates through a Pub/Sub subscription.\n\n1. Visit the [Device Access Console]({device_access_console_url}) and ensure a Pub/Sub topic is configured.\n1. Visit the [Cloud Console]({url}) to find your Google Cloud Project ID and confirm it is correct below.\n1. The next step will attempt to auto-discover Pub/Sub topics and subscriptions.\n\nSee the integration documentation for [more info]({more_info_url}).", "data": { "cloud_project_id": "[%key:component::nest::config::step::cloud_project::data::cloud_project_id%]" } diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index 72087dd28db35e..4751e58a6a18a3 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -47,18 +47,21 @@ key="type", translation_key="link_type", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "link_rate": SensorEntityDescription( key="link_rate", translation_key="link_rate", native_unit_of_measurement="Mbps", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "signal": SensorEntityDescription( key="signal", translation_key="signal_strength", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ssid": SensorEntityDescription( key="ssid", @@ -69,6 +72,7 @@ key="conn_ap_mac", translation_key="access_point_mac", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), } @@ -326,8 +330,6 @@ def new_device_callback() -> None: class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity): """Representation of a device connected to a Netgear router.""" - _attr_entity_registry_enabled_default = False - def __init__( self, coordinator: DataUpdateCoordinator, diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 02ef16b678792a..c4f030ebe9f491 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -1,7 +1,7 @@ { "domain": "onvif", "name": "ONVIF", - "codeowners": ["@hunterjm"], + "codeowners": ["@hunterjm", "@jterrace"], "config_flow": true, "dependencies": ["ffmpeg"], "dhcp": [{ "registered_devices": true }], diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index d7bbaa4fb3fa44..9904a4bbfa93b6 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine +import dataclasses import datetime from typing import Any @@ -370,22 +371,56 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: return None -@PARSERS.register("tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent") +_TAPO_EVENT_TEMPLATES: dict[str, Event] = { + "IsVehicle": Event( + uid="", + name="Vehicle Detection", + platform="binary_sensor", + device_class="motion", + ), + "IsPeople": Event( + uid="", name="Person Detection", platform="binary_sensor", device_class="motion" + ), + "IsLineCross": Event( + uid="", + name="Line Detector Crossed", + platform="binary_sensor", + device_class="motion", + ), + "IsTamper": Event( + uid="", name="Tamper Detection", platform="binary_sensor", device_class="tamper" + ), + "IsIntrusion": Event( + uid="", + name="Intrusion Detection", + platform="binary_sensor", + device_class="safety", + ), +} + + +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Intrusion") +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/LineCross") +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/People") +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Tamper") +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/TpSmartEvent") @PARSERS.register("tns1:RuleEngine/PeopleDetector/People") +@PARSERS.register("tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent") async def async_parse_tplink_detector(uid: str, msg) -> Event | None: """Handle parsing tplink smart event messages. - Topic: tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent + Topic: tns1:RuleEngine/CellMotionDetector/Intrusion + Topic: tns1:RuleEngine/CellMotionDetector/LineCross + Topic: tns1:RuleEngine/CellMotionDetector/People + Topic: tns1:RuleEngine/CellMotionDetector/Tamper + Topic: tns1:RuleEngine/CellMotionDetector/TpSmartEvent Topic: tns1:RuleEngine/PeopleDetector/People + Topic: tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent """ - video_source = "" - video_analytics = "" - rule = "" - topic = "" - vehicle = False - person = False - enabled = False try: + video_source = "" + video_analytics = "" + rule = "" topic, payload = extract_message(msg) for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": @@ -396,34 +431,19 @@ async def async_parse_tplink_detector(uid: str, msg) -> Event | None: rule = source.Value for item in payload.Data.SimpleItem: - if item.Name == "IsVehicle": - vehicle = True - enabled = item.Value == "true" - if item.Name == "IsPeople": - person = True - enabled = item.Value == "true" + event_template = _TAPO_EVENT_TEMPLATES.get(item.Name, None) + if event_template is None: + continue + + return dataclasses.replace( + event_template, + uid=f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + value=item.Value == "true", + ) + except (AttributeError, KeyError): return None - if vehicle: - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Vehicle Detection", - "binary_sensor", - "motion", - None, - enabled, - ) - if person: - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Person Detection", - "binary_sensor", - "motion", - None, - enabled, - ) - return None diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json index 844cbb4ca98950..e380711303d461 100644 --- a/homeassistant/components/switcher_kis/strings.json +++ b/homeassistant/components/switcher_kis/strings.json @@ -63,6 +63,14 @@ "temperature": { "name": "Current temperature" } + }, + "switch": { + "child_lock": { + "name": "Child lock" + }, + "multi_child_lock": { + "name": "Child lock {cover_id}" + } } }, "services": { diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index ba0a99b408908c..7d3d71a0615a4d 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -4,14 +4,15 @@ from datetime import timedelta import logging -from typing import Any +from typing import Any, cast -from aioswitcher.api import Command -from aioswitcher.device import DeviceCategory, DeviceState +from aioswitcher.api import Command, ShutterChildLock +from aioswitcher.device import DeviceCategory, DeviceState, SwitcherShutter import voluptuous as vol from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -32,6 +33,7 @@ API_CONTROL_DEVICE = "control_device" API_SET_AUTO_SHUTDOWN = "set_auto_shutdown" +API_SET_CHILD_LOCK = "set_shutter_child_lock" SERVICE_SET_AUTO_OFF_SCHEMA: VolDictType = { vol.Required(CONF_AUTO_OFF): cv.time_period_str, @@ -67,10 +69,28 @@ async def async_setup_entry( @callback def async_add_switch(coordinator: SwitcherDataUpdateCoordinator) -> None: """Add switch from Switcher device.""" + entities: list[SwitchEntity] = [] + if coordinator.data.device_type.category == DeviceCategory.POWER_PLUG: - async_add_entities([SwitcherPowerPlugSwitchEntity(coordinator)]) + entities.append(SwitcherPowerPlugSwitchEntity(coordinator)) elif coordinator.data.device_type.category == DeviceCategory.WATER_HEATER: - async_add_entities([SwitcherWaterHeaterSwitchEntity(coordinator)]) + entities.append(SwitcherWaterHeaterSwitchEntity(coordinator)) + elif coordinator.data.device_type.category in ( + DeviceCategory.SHUTTER, + DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT, + DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT, + ): + number_of_covers = len(cast(SwitcherShutter, coordinator.data).position) + if number_of_covers == 1: + entities.append( + SwitchereShutterChildLockSingleSwitchEntity(coordinator, 0) + ) + else: + entities.extend( + SwitchereShutterChildLockMultiSwitchEntity(coordinator, i) + for i in range(number_of_covers) + ) + async_add_entities(entities) config_entry.async_on_unload( async_dispatcher_connect(hass, SIGNAL_DEVICE_ADD, async_add_switch) @@ -154,3 +174,91 @@ async def async_turn_on_with_timer_service(self, timer_minutes: int) -> None: await self._async_call_api(API_CONTROL_DEVICE, Command.ON, timer_minutes) self.control_result = True self.async_write_ha_state() + + +class SwitchereShutterChildLockBaseSwitchEntity(SwitcherEntity, SwitchEntity): + """Representation of a Switcher shutter base switch entity.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + _attr_entity_category = EntityCategory.CONFIG + _attr_icon = "mdi:lock-open" + _cover_id: int + + def __init__(self, coordinator: SwitcherDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.control_result: bool | None = None + + @callback + def _handle_coordinator_update(self) -> None: + """When device updates, clear control result that overrides state.""" + self.control_result = None + super()._handle_coordinator_update() + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + if self.control_result is not None: + return self.control_result + + data = cast(SwitcherShutter, self.coordinator.data) + return bool(data.child_lock[self._cover_id] == ShutterChildLock.ON) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self._async_call_api( + API_SET_CHILD_LOCK, ShutterChildLock.ON, self._cover_id + ) + self.control_result = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._async_call_api( + API_SET_CHILD_LOCK, ShutterChildLock.OFF, self._cover_id + ) + self.control_result = False + self.async_write_ha_state() + + +class SwitchereShutterChildLockSingleSwitchEntity( + SwitchereShutterChildLockBaseSwitchEntity +): + """Representation of a Switcher runner child lock single switch entity.""" + + _attr_translation_key = "child_lock" + + def __init__( + self, + coordinator: SwitcherDataUpdateCoordinator, + cover_id: int, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._cover_id = cover_id + + self._attr_unique_id = ( + f"{coordinator.device_id}-{coordinator.mac_address}-child_lock" + ) + + +class SwitchereShutterChildLockMultiSwitchEntity( + SwitchereShutterChildLockBaseSwitchEntity +): + """Representation of a Switcher runner child lock multiple switch entity.""" + + _attr_translation_key = "multi_child_lock" + + def __init__( + self, + coordinator: SwitcherDataUpdateCoordinator, + cover_id: int, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._cover_id = cover_id + + self._attr_translation_placeholders = {"cover_id": str(cover_id + 1)} + self._attr_unique_id = ( + f"{coordinator.device_id}-{coordinator.mac_address}-{cover_id}-child_lock" + ) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 4f4bc2ae60c58d..736762dc6f40b8 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -610,7 +610,7 @@ }, "services": { "navigation_gps_request": { - "description": "Set vehicle navigation to the provided latitude/longitude coordinates.", + "description": "Sets vehicle navigation to the provided latitude/longitude coordinates.", "fields": { "device_id": { "description": "Vehicle to share to.", @@ -646,7 +646,7 @@ "name": "Set scheduled charging" }, "set_scheduled_departure": { - "description": "Sets a time at which departure should be completed.", + "description": "Sets the departure time for a vehicle to schedule charging and preconditioning.", "fields": { "departure_time": { "description": "Time to be preconditioned by.", @@ -684,7 +684,7 @@ "name": "Set scheduled departure" }, "speed_limit": { - "description": "Activate the speed limit of the vehicle.", + "description": "Activates the speed limit of a vehicle.", "fields": { "device_id": { "description": "Vehicle to limit.", @@ -702,7 +702,7 @@ "name": "Set speed limit" }, "time_of_use": { - "description": "Update the time of use settings for the energy site.", + "description": "Updates the time of use settings for an energy site.", "fields": { "device_id": { "description": "Energy Site to configure.", @@ -716,7 +716,7 @@ "name": "Time of use settings" }, "valet_mode": { - "description": "Activate the valet mode of the vehicle.", + "description": "Activates the valet mode of a vehicle.", "fields": { "device_id": { "description": "Vehicle to limit.", diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index f3fbd3e06105e8..3bf3bc82dc19a4 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -20,7 +20,7 @@ from homeassistant.core import callback from homeassistant.helpers import config_validation as cv -from . import async_control_connect, update_client_key +from . import async_control_connect from .const import CONF_SOURCES, DEFAULT_NAME, DOMAIN, WEBOSTV_EXCEPTIONS from .helpers import async_get_sources @@ -53,14 +53,11 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" - errors: dict[str, str] = {} if user_input is not None: self._host = user_input[CONF_HOST] return await self.async_step_pairing() - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors - ) + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) async def async_step_pairing( self, user_input: dict[str, Any] | None = None @@ -69,13 +66,13 @@ async def async_step_pairing( self._async_abort_entries_match({CONF_HOST: self._host}) self.context["title_placeholders"] = {"name": self._name} - errors = {} + errors: dict[str, str] = {} if user_input is not None: try: client = await async_control_connect(self._host, None) except WebOsTvPairError: - return self.async_abort(reason="error_pairing") + errors["base"] = "error_pairing" except WEBOSTV_EXCEPTIONS: errors["base"] = "cannot_connect" else: @@ -130,20 +127,56 @@ async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + if user_input is not None: try: client = await async_control_connect(self._host, None) except WebOsTvPairError: - return self.async_abort(reason="error_pairing") + errors["base"] = "error_pairing" except WEBOSTV_EXCEPTIONS: - return self.async_abort(reason="reauth_unsuccessful") + errors["base"] = "cannot_connect" + else: + reauth_entry = self._get_reauth_entry() + data = {CONF_HOST: self._host, CONF_CLIENT_SECRET: client.client_key} + return self.async_update_reload_and_abort(reauth_entry, data=data) + + return self.async_show_form(step_id="reauth_confirm", errors=errors) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() - reauth_entry = self._get_reauth_entry() - update_client_key(self.hass, reauth_entry, client) - await self.hass.config_entries.async_reload(reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") + if user_input is not None: + host = user_input[CONF_HOST] + client_key = reconfigure_entry.data.get(CONF_CLIENT_SECRET) + + try: + client = await async_control_connect(host, client_key) + except WebOsTvPairError: + errors["base"] = "error_pairing" + except WEBOSTV_EXCEPTIONS: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(client.hello_info["deviceUUID"]) + self._abort_if_unique_id_mismatch(reason="wrong_device") + data = {CONF_HOST: host, CONF_CLIENT_SECRET: client.client_key} + return self.async_update_reload_and_abort(reconfigure_entry, data=data) - return self.async_show_form(step_id="reauth_confirm") + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, default=reconfigure_entry.data.get(CONF_HOST) + ): cv.string + } + ), + errors=errors, + ) class OptionsFlowHandler(OptionsFlow): diff --git a/homeassistant/components/webostv/quality_scale.yaml b/homeassistant/components/webostv/quality_scale.yaml index 22c0b4155abe72..3a31c20f256f94 100644 --- a/homeassistant/components/webostv/quality_scale.yaml +++ b/homeassistant/components/webostv/quality_scale.yaml @@ -7,9 +7,7 @@ rules: status: exempt comment: The integration does not use common patterns. config-flow-test-coverage: done - config-flow: - status: todo - comment: make reauth flow more graceful + config-flow: done dependency-transparency: done docs-actions: status: todo @@ -66,7 +64,7 @@ rules: icon-translations: status: exempt comment: The only entity can use the device class. - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: The integration does not have anything to repair. diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index 34c1b44e195705..b0786bd06de67d 100644 --- a/homeassistant/components/webostv/strings.json +++ b/homeassistant/components/webostv/strings.json @@ -8,7 +8,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "Hostname or IP address of your webOS TV." + "host": "Hostname or IP address of your LG webOS TV." } }, "pairing": { @@ -18,17 +18,26 @@ "reauth_confirm": { "title": "[%key:component::webostv::config::step::pairing::title%]", "description": "[%key:component::webostv::config::step::pairing::description%]" + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::webostv::config::step::user::data_description::host%]" + } } }, "error": { - "cannot_connect": "Failed to connect, please turn on your TV or check the IP address" + "cannot_connect": "Failed to connect, please turn on your TV and try again.", + "error_pairing": "Pairing failed, make sure to accept the pairing request on the TV and try again." }, "abort": { - "error_pairing": "Connected to LG webOS TV but not paired", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "reauth_unsuccessful": "Re-authentication was unsuccessful, please turn on your TV and try again." + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "wrong_device": "The configured device is not the same found on this Hostname or IP address." } }, "options": { @@ -38,6 +47,9 @@ "description": "Select enabled sources", "data": { "sources": "Sources list" + }, + "data_description": { + "sources": "List of sources to enable" } } }, diff --git a/tests/components/habitica/fixtures/content.json b/tests/components/habitica/fixtures/content.json index b4458aa647af68..e26dbeb17ccd11 100644 --- a/tests/components/habitica/fixtures/content.json +++ b/tests/components/habitica/fixtures/content.json @@ -370,7 +370,109 @@ "animalSetAchievements": {}, "stableAchievements": {}, "petSetCompleteAchievs": [], - "quests": {}, + "quests": { + "atom1": { + "text": "Angriff des Banalen, Teil 1: Abwasch-Katastrophe!", + "notes": "Du erreichst die Ufer des Waschbeckensees für eine wohlverdiente Auszeit ... Aber der See ist verschmutzt mit nicht abgespültem Geschirr! Wie ist das passiert? Wie auch immer, Du kannst den See jedenfalls nicht in diesem Zustand lassen. Es gibt nur eine Sache die Du tun kannst: Abspülen und den Ferienort retten! Dazu musst Du aber Seife für den Abwasch finden. Viel Seife ...", + "completion": "Nach gründlichem Schrubben ist das Geschirr sicher am Ufer gestapelt! Du trittst zurück und begutachtest stolz Deiner Hände Arbeit.", + "group": "questGroupAtom", + "prerequisite": { + "lvl": 15 + }, + "value": 4, + "lvl": 15, + "category": "unlockable", + "collect": { + "soapBars": { + "text": "Seifenstücke", + "count": 20 + } + }, + "drop": { + "items": [ + { + "type": "quests", + "key": "atom2", + "text": "Das Monster vom KochLess (Schriftrolle)", + "onlyOwner": true + } + ], + "gp": 7, + "exp": 50 + }, + "key": "atom1" + }, + "goldenknight1": { + "text": "Die goldene Ritterin, Teil 1: Ein ernstes Gespräch", + "notes": "Die goldene Ritterin ist Habiticanern mit ihrer Kritik ganz schön auf die Nerven gegangen. Nicht alle Tagesaufgaben erledigt? Eine negative Gewohnheit angeklickt? Sie nimmt dies zum Anlass Dich zu bedrängen, dass Du doch ihrem Beispiel folgen sollst. Sie ist das leuchtende Beispiel eines perfekten Habiticaners und Du bist nichts als ein Versager. Das ist ja mal gar nicht nett! Jeder macht Fehler. Man sollte deshalb nicht mit solcher Kritik drangsaliert werden. Vielleicht solltest Du einige Zeugenaussagen von verletzten Habiticanern zusammentragen und die Goldene Ritterin mal ordentlich zurechtweisen!", + "completion": "Schau Dir nur all diese Zeugenaussagen an! Bestimmt wird das reichen, um die Goldene Ritterin zu überzeugen. Nun musst Du sie nur noch finden.", + "group": "questGroupGoldenknight", + "value": 4, + "lvl": 40, + "category": "unlockable", + "collect": { + "testimony": { + "text": "Zeugenaussagen", + "count": 60 + } + }, + "drop": { + "items": [ + { + "type": "quests", + "key": "goldenknight2", + "text": "Die goldene Ritterin Teil 2: Die goldene Ritterin (Schriftrolle)", + "onlyOwner": true + } + ], + "gp": 15, + "exp": 120 + }, + "key": "goldenknight1" + }, + "dustbunnies": { + "text": "Die ungezähmten Staubmäuse", + "notes": "Es ist schon etwas her, seit Du hier drinnen das letzte Mal Staub gewischt hast, aber Du sorgst dich nicht allzusehr - ein Wenig Staub hat noch nie jemandem geschadet, oder? Erst, als Du Deine Hand in eine der staubigsten Ecken steckst und einen Biss spürst, erinnerst du dich an @InspectorCaracals Warnung: Harmlosen Staub zu lange in Ruhe zu lassen, verwandelt ihn in boshafte Staubmäuse! Du solltest sie besser besiegen, bevor sie ganz Habitica mit feinen Schmutzpartikeln bedecken!", + "group": "questGroupEarnable", + "completion": "Die Staubmäuse verschwinden in einer Rauch-, äh… Staubwolke. Als sich der Staub legt, siehst du dich um. Du hast vergessen, wie hübsch es hier doch aussieht, wenn es sauber ist. Du erkennst einen kleinen Haufen Gold, wo der Staub vorher war. Huch, du hattest dich schon gefragt, wo er abgeblieben war!", + "value": 1, + "category": "unlockable", + "boss": { + "name": "Ungezähmte Staubmäuse", + "hp": 100, + "str": 0.5, + "def": 1 + }, + "drop": { + "gp": 8, + "exp": 42 + }, + "key": "dustbunnies" + }, + "basilist": { + "text": "Der Basi-List", + "notes": "Da ist ein Aufruhr auf dem Marktplatz – es sieht ganz so aus, als ob man lieber in die andere Richtung rennen sollte. Da Du aber ein mutiger Abenteurer bist, rennst Du stattdessen darauf zu und entdeckst einen Basi-List, der sich aus einem Haufen unerledigter Aufgaben geformt hat! Alle umstehenden Habiticaner sind aus Angst vor der Länge des Basi-Lists gelähmt und können nicht anfangen zu arbeiten. Von irgendwo in der Nähe hörst Du @Arcosine schreien: \"Schnell! Erledige Deine To-Dos und Tagesaufgaben, um dem Monster die Zähne zu entfernen, bevor sich jemand am Papier schneidet!\" Greife schnell an, Abenteurer, und hake etwas ab - aber Vorsicht! Wenn Du irgendwelche Tagesaufgaben nicht erledigst, wird der Basi-List Dich und Deine Party angreifen!", + "group": "questGroupEarnable", + "completion": "Der Basi-List ist in Papierschnitzel zerfallen, die sanft in Regenbogenfarben schimmern. \"Puh!\" sagt @Arcosine. \"Gut, dass ihr gerade hier wart!\" Du fühlst Dich erfahrener als vorher und sammelst ein paar verstreute Goldstücke zwischen den Papierstücken auf.", + "goldValue": 100, + "category": "unlockable", + "unlockCondition": { + "condition": "party invite", + "text": "Lade Freunde ein" + }, + "boss": { + "name": "Der Basi-List", + "hp": 100, + "str": 0.5, + "def": 1 + }, + "drop": { + "gp": 8, + "exp": 42 + }, + "key": "basilist" + } + }, "questsByLevel": {}, "userCanOwnQuestCategories": [], "itemList": { @@ -450,11 +552,61 @@ "special": {}, "dropEggs": {}, "questEggs": {}, - "eggs": {}, + "eggs": { + "Wolf": { + "text": "Wolfsjunges", + "mountText": "Wolfs-Reittier", + "adjective": "ein treues", + "value": 3, + "key": "Wolf", + "notes": "Finde ein Schlüpfelixier, das Du über dieses Ei gießen kannst, damit ein ein treues Wolfsjunges schlüpfen kann." + }, + "TigerCub": { + "text": "Tigerjunges", + "mountText": "Tiger-Reittier", + "adjective": "ein wildes", + "value": 3, + "key": "TigerCub", + "notes": "Finde ein Schlüpfelixier, das Du über dieses Ei gießen kannst, damit ein ein wildes Tigerjunges schlüpfen kann." + }, + "PandaCub": { + "text": "Pandajunges", + "mountText": "Panda-Reittier", + "adjective": "ein sanftes", + "value": 3, + "key": "PandaCub", + "notes": "Finde ein Schlüpfelixier, das Du über dieses Ei gießen kannst, damit ein ein sanftes Pandajunges schlüpfen kann." + } + }, "dropHatchingPotions": {}, "premiumHatchingPotions": {}, "wackyHatchingPotions": {}, - "hatchingPotions": {}, + "hatchingPotions": { + "Base": { + "value": 2, + "key": "Base", + "text": "Normales", + "notes": "Gieße dies über ein Ei und es wird ein Normales Haustier daraus schlüpfen.", + "premium": false, + "limited": false + }, + "White": { + "value": 2, + "key": "White", + "text": "Weißes", + "notes": "Gieße dies über ein Ei und es wird ein Weißes Haustier daraus schlüpfen.", + "premium": false, + "limited": false + }, + "Desert": { + "value": 2, + "key": "Desert", + "text": "Wüstenfarbenes", + "notes": "Gieße dies über ein Ei und es wird ein Wüstenfarbenes Haustier daraus schlüpfen.", + "premium": false, + "limited": false + } + }, "pets": {}, "premiumPets": {}, "questPets": {}, @@ -466,7 +618,46 @@ "questMounts": {}, "specialMounts": {}, "mountInfo": {}, - "food": {} + "food": { + "Meat": { + "text": "Fleisch", + "textA": "Fleisch", + "textThe": "das Fleisch", + "target": "Base", + "value": 1, + "key": "Meat", + "notes": "Verfüttere dies an ein Haustier und es wächst bald zu einem kräftigen Reittier heran.", + "canDrop": true + }, + "Milk": { + "text": "Milch", + "textA": "Milch", + "textThe": "die Milch", + "target": "White", + "value": 1, + "key": "Milk", + "notes": "Verfüttere dies an ein Haustier und es wächst bald zu einem kräftigen Reittier heran.", + "canDrop": true + }, + "Potatoe": { + "text": "Kartoffel", + "textA": "eine Kartoffel", + "textThe": "die Kartoffel", + "target": "Desert", + "value": 1, + "key": "Potatoe", + "notes": "Verfüttere dies an ein Haustier und es wächst bald zu einem kräftigen Reittier heran.", + "canDrop": true + }, + "Saddle": { + "sellWarningNote": "Hey! Das ist ein sehr nützlicher Gegenstand! Weißt Du, wie Du den Sattel mit Deinen Haustieren nutzt?", + "text": "Magischer Sattel", + "value": 5, + "notes": "Lässt eines Deiner Haustiere augenblicklich zum Reittier heranwachsen.", + "canDrop": false, + "key": "Saddle" + } + } }, "appVersion": "5.29.2" } diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index 876ea2550d3137..255d9c7c3b5318 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -112,6 +112,28 @@ "eyewear": "eyewear_armoire_plagueDoctorMask", "body": "body_special_aetherAmulet" } + }, + "quests": { + "atom1": 1, + "goldenknight1": 0, + "dustbunnies": 1, + "basilist": 0 + }, + "food": { + "Saddle": 2, + "Meat": 0, + "Milk": 1, + "Potatoe": 2 + }, + "hatchingPotions": { + "Base": 2, + "White": 0, + "Desert": 1 + }, + "eggs": { + "Wolf": 1, + "TigerCub": 0, + "PandaCub": 2 } }, "balance": 10, diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 7464a5fd36d028..b217a1418b9a46 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -160,6 +160,57 @@ 'state': 'test-user', }) # --- +# name: test_sensors[sensor.test_user_eggs-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_eggs', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Eggs', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_eggs_total', + 'unit_of_measurement': 'eggs', + }) +# --- +# name: test_sensors[sensor.test_user_eggs-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Pandajunges': 2, + 'Tigerjunges': 0, + 'Wolfsjunges': 1, + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_Egg_Egg.png', + 'friendly_name': 'test-user Eggs', + 'unit_of_measurement': 'eggs', + }), + 'context': , + 'entity_id': 'sensor.test_user_eggs', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- # name: test_sensors[sensor.test_user_experience-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -514,6 +565,57 @@ 'state': '4', }) # --- +# name: test_sensors[sensor.test_user_hatching_potions-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_hatching_potions', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hatching potions', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_hatching_potions_total', + 'unit_of_measurement': 'potions', + }) +# --- +# name: test_sensors[sensor.test_user_hatching_potions-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Normales': 2, + 'Weißes': 0, + 'Wüstenfarbenes': 1, + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_RoyalPurple.png', + 'friendly_name': 'test-user Hatching potions', + 'unit_of_measurement': 'potions', + }), + 'context': , + 'entity_id': 'sensor.test_user_hatching_potions', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- # name: test_sensors[sensor.test_user_health-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -962,6 +1064,109 @@ 'state': '75', }) # --- +# name: test_sensors[sensor.test_user_pet_food-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_pet_food', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pet food', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_food_total', + 'unit_of_measurement': 'foods', + }) +# --- +# name: test_sensors[sensor.test_user_pet_food-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Fleisch': 0, + 'Kartoffel': 2, + 'Milch': 1, + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_Food_Strawberry.png', + 'friendly_name': 'test-user Pet food', + 'unit_of_measurement': 'foods', + }), + 'context': , + 'entity_id': 'sensor.test_user_pet_food', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensors[sensor.test_user_quest_scrolls-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_quest_scrolls', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Quest scrolls', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_quest_scrolls', + 'unit_of_measurement': 'scrolls', + }) +# --- +# name: test_sensors[sensor.test_user_quest_scrolls-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Angriff des Banalen, Teil 1: Abwasch-Katastrophe!': 1, + 'Der Basi-List': 0, + 'Die goldene Ritterin, Teil 1: Ein ernstes Gespräch': 0, + 'Die ungezähmten Staubmäuse': 1, + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/inventory_quest_scroll_dustbunnies.png', + 'friendly_name': 'test-user Quest scrolls', + 'unit_of_measurement': 'scrolls', + }), + 'context': , + 'entity_id': 'sensor.test_user_quest_scrolls', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- # name: test_sensors[sensor.test_user_rewards-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1048,6 +1253,54 @@ 'state': '1', }) # --- +# name: test_sensors[sensor.test_user_saddles-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_saddles', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Saddles', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_saddle', + 'unit_of_measurement': 'saddles', + }) +# --- +# name: test_sensors[sensor.test_user_saddles-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_Food_Saddle.png', + 'friendly_name': 'test-user Saddles', + 'unit_of_measurement': 'saddles', + }), + 'context': , + 'entity_id': 'sensor.test_user_saddles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- # name: test_sensors[sensor.test_user_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/onvif/test_parsers.py b/tests/components/onvif/test_parsers.py index 209e7cbccef7fd..16172112c11374 100644 --- a/tests/components/onvif/test_parsers.py +++ b/tests/components/onvif/test_parsers.py @@ -119,7 +119,83 @@ async def test_line_detector_crossed(hass: HomeAssistant) -> None: ) -async def test_tapo_vehicle(hass: HomeAssistant) -> None: +async def test_tapo_line_crossed(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/LineCross.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/LineCross", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyLineCrossDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsLineCross", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 3, 21, 5, 14, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Line Detector Crossed" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "LineCross_VideoSourceToken_VideoAnalyticsToken_MyLineCrossDetectorRule" + ) + + +async def test_tapo_tpsmartevent_vehicle(hass: HomeAssistant) -> None: """Tests tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent - vehicle.""" event = await get_event( { @@ -198,7 +274,83 @@ async def test_tapo_vehicle(hass: HomeAssistant) -> None: ) -async def test_tapo_person(hass: HomeAssistant) -> None: +async def test_tapo_cellmotiondetector_vehicle(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/TpSmartEvent - vehicle.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/TpSmartEvent", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyTPSmartEventDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsVehicle", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 5, 14, 2, 9, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Vehicle Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "TpSmartEvent_VideoSourceToken_VideoAnalyticsToken_MyTPSmartEventDetectorRule" + ) + + +async def test_tapo_tpsmartevent_person(hass: HomeAssistant) -> None: """Tests tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent - person.""" event = await get_event( { @@ -274,6 +426,234 @@ async def test_tapo_person(hass: HomeAssistant) -> None: ) +async def test_tapo_cellmotiondetector_person(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/People - person.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://192.168.56.63:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/People", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://192.168.56.63:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyPeopleDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsPeople", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 3, 20, 9, 22, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Person Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "People_VideoSourceToken_VideoAnalyticsToken_MyPeopleDetectorRule" + ) + + +async def test_tapo_tamper(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/Tamper - tamper.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/Tamper", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyTamperDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsTamper", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 5, 21, 1, 5, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Tamper Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "tamper" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "Tamper_VideoSourceToken_VideoAnalyticsToken_MyTamperDetectorRule" + ) + + +async def test_tapo_intrusion(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/Intrusion - intrusion.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://192.168.100.155:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/Intrusion", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://192.168.100.155:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyIntrusionDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsIntrusion", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 11, 10, 40, 45, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Intrusion Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "safety" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "Intrusion_VideoSourceToken_VideoAnalyticsToken_MyIntrusionDetectorRule" + ) + + async def test_tapo_missing_attributes(hass: HomeAssistant) -> None: """Tests async_parse_tplink_detector with missing fields.""" event = await get_event( diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py index defe970c6749c3..57454e38062a8c 100644 --- a/tests/components/switcher_kis/consts.py +++ b/tests/components/switcher_kis/consts.py @@ -91,8 +91,8 @@ DUMMY_POSITION_2 = [54, 54] DUMMY_DIRECTION = [ShutterDirection.SHUTTER_STOP] DUMMY_DIRECTION_2 = [ShutterDirection.SHUTTER_STOP, ShutterDirection.SHUTTER_STOP] -DUMMY_CHILD_LOCK = [ShutterChildLock.OFF] -DUMMY_CHILD_LOCK_2 = [ShutterChildLock.OFF, ShutterChildLock.OFF] +DUMMY_CHILD_LOCK = [ShutterChildLock.ON] +DUMMY_CHILD_LOCK_2 = [ShutterChildLock.ON, ShutterChildLock.ON] DUMMY_USERNAME = "email" DUMMY_TOKEN = "zvVvd7JxtN7CgvkD1Psujw==" DUMMY_LIGHT = [DeviceState.ON] diff --git a/tests/components/switcher_kis/test_switch.py b/tests/components/switcher_kis/test_switch.py index 9bfe11fe202f22..c20149de074c97 100644 --- a/tests/components/switcher_kis/test_switch.py +++ b/tests/components/switcher_kis/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from aioswitcher.api import Command, SwitcherBaseResponse +from aioswitcher.api import Command, ShutterChildLock, SwitcherBaseResponse from aioswitcher.device import DeviceState import pytest @@ -20,7 +20,20 @@ from homeassistant.util import slugify from . import init_integration -from .consts import DUMMY_PLUG_DEVICE, DUMMY_WATER_HEATER_DEVICE +from .consts import ( + DUMMY_DUAL_SHUTTER_SINGLE_LIGHT_DEVICE as DEVICE3, + DUMMY_PLUG_DEVICE, + DUMMY_SHUTTER_DEVICE as DEVICE, + DUMMY_SINGLE_SHUTTER_DUAL_LIGHT_DEVICE as DEVICE2, + DUMMY_TOKEN as TOKEN, + DUMMY_USERNAME as USERNAME, + DUMMY_WATER_HEATER_DEVICE, +) + +ENTITY_ID = f"{SWITCH_DOMAIN}.{slugify(DEVICE.name)}_child_lock" +ENTITY_ID2 = f"{SWITCH_DOMAIN}.{slugify(DEVICE2.name)}_child_lock" +ENTITY_ID3 = f"{SWITCH_DOMAIN}.{slugify(DEVICE3.name)}_child_lock_1" +ENTITY_ID3_2 = f"{SWITCH_DOMAIN}.{slugify(DEVICE3.name)}_child_lock_2" @pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) @@ -137,3 +150,192 @@ async def test_switch_control_fail( mock_control_device.assert_called_once_with(Command.ON) state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ( + "device", + "entity_id", + "cover_id", + "child_lock_state", + ), + [ + ( + DEVICE, + ENTITY_ID, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE2, + ENTITY_ID2, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE3, + ENTITY_ID3, + 0, + [ShutterChildLock.OFF, ShutterChildLock.ON], + ), + ( + DEVICE3, + ENTITY_ID3_2, + 1, + [ShutterChildLock.ON, ShutterChildLock.OFF], + ), + ], +) +@pytest.mark.parametrize("mock_bridge", [[DEVICE, DEVICE2, DEVICE3]], indirect=True) +async def test_child_lock_switch( + hass: HomeAssistant, + mock_bridge, + mock_api, + monkeypatch: pytest.MonkeyPatch, + device, + entity_id: str, + cover_id: int, + child_lock_state: list[ShutterChildLock], +) -> None: + """Test the switch.""" + await init_integration(hass, USERNAME, TOKEN) + assert mock_bridge + + # Test initial state - on + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test state change on --> off + monkeypatch.setattr(device, "child_lock", child_lock_state) + mock_bridge.mock_callbacks([device]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test turning on child lock + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock", + ) as mock_control_device: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(ShutterChildLock.ON, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test turning off + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock" + ) as mock_control_device: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(ShutterChildLock.OFF, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + ( + "device", + "entity_id", + "cover_id", + "child_lock_state", + ), + [ + ( + DEVICE, + ENTITY_ID, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE2, + ENTITY_ID2, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE3, + ENTITY_ID3, + 0, + [ShutterChildLock.OFF, ShutterChildLock.ON], + ), + ( + DEVICE3, + ENTITY_ID3_2, + 1, + [ShutterChildLock.ON, ShutterChildLock.OFF], + ), + ], +) +@pytest.mark.parametrize("mock_bridge", [[DEVICE, DEVICE2, DEVICE3]], indirect=True) +async def test_child_lock_control_fail( + hass: HomeAssistant, + mock_bridge, + mock_api, + monkeypatch: pytest.MonkeyPatch, + device, + entity_id: str, + cover_id: int, + child_lock_state: list[ShutterChildLock], +) -> None: + """Test switch control fail.""" + await init_integration(hass, USERNAME, TOKEN) + assert mock_bridge + + # Test initial state - off + monkeypatch.setattr(device, "child_lock", child_lock_state) + mock_bridge.mock_callbacks([device]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test exception during turn on + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock", + side_effect=RuntimeError("fake error"), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(ShutterChildLock.ON, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + # Make device available again + mock_bridge.mock_callbacks([device]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test error response during turn on + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock", + return_value=SwitcherBaseResponse(None), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(ShutterChildLock.ON, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/webostv/test_config_flow.py b/tests/components/webostv/test_config_flow.py index 608e3bd306a902..c8ac54be4bd981 100644 --- a/tests/components/webostv/test_config_flow.py +++ b/tests/components/webostv/test_config_flow.py @@ -1,7 +1,5 @@ """Test the WebOS Tv config flow.""" -from unittest.mock import AsyncMock - from aiowebostv import WebOsTvPairError import pytest @@ -105,7 +103,7 @@ async def test_options_flow_cannot_retrieve(hass: HomeAssistant, client) -> None """Test options config flow cannot retrieve sources.""" entry = await setup_webostv(hass) - client.connect = AsyncMock(side_effect=ConnectionRefusedError()) + client.connect.side_effect = ConnectionRefusedError result = await hass.config_entries.options.async_init(entry.entry_id) await hass.async_block_till_done() @@ -113,7 +111,7 @@ async def test_options_flow_cannot_retrieve(hass: HomeAssistant, client) -> None assert result["errors"] == {"base": "cannot_retrieve"} # recover - client.connect = AsyncMock(return_value=True) + client.connect.side_effect = None result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=None, @@ -139,7 +137,7 @@ async def test_form_cannot_connect(hass: HomeAssistant, client) -> None: data=MOCK_USER_CONFIG, ) - client.connect = AsyncMock(side_effect=ConnectionRefusedError()) + client.connect.side_effect = ConnectionRefusedError result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) @@ -148,7 +146,7 @@ async def test_form_cannot_connect(hass: HomeAssistant, client) -> None: assert result["errors"] == {"base": "cannot_connect"} # recover - client.connect = AsyncMock(return_value=True) + client.connect.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) @@ -165,13 +163,22 @@ async def test_form_pairexception(hass: HomeAssistant, client) -> None: data=MOCK_USER_CONFIG, ) - client.connect = AsyncMock(side_effect=WebOsTvPairError("error")) + client.connect.side_effect = WebOsTvPairError result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "error_pairing" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "error_pairing"} + + # recover + client.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TV_NAME async def test_entry_already_configured(hass: HomeAssistant, client) -> None: @@ -267,9 +274,7 @@ async def test_form_abort_uuid_configured(hass: HomeAssistant, client) -> None: assert entry.data[CONF_HOST] == "new_host" -async def test_reauth_successful( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_reauth_successful(hass: HomeAssistant, client) -> None: """Test that the reauthorization is successful.""" entry = await setup_webostv(hass) @@ -282,7 +287,7 @@ async def test_reauth_successful( assert result["step_id"] == "reauth_confirm" assert entry.data[CONF_CLIENT_SECRET] == CLIENT_KEY - monkeypatch.setattr(client, "client_key", "new_key") + client.client_key = "new_key" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) @@ -293,15 +298,13 @@ async def test_reauth_successful( @pytest.mark.parametrize( - ("side_effect", "reason"), + ("side_effect", "error"), [ (WebOsTvPairError, "error_pairing"), - (ConnectionRefusedError, "reauth_unsuccessful"), + (ConnectionRefusedError, "cannot_connect"), ], ) -async def test_reauth_errors( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch, side_effect, reason -) -> None: +async def test_reauth_errors(hass: HomeAssistant, client, side_effect, error) -> None: """Test reauthorization errors.""" entry = await setup_webostv(hass) @@ -318,5 +321,88 @@ async def test_reauth_errors( result["flow_id"], user_input={} ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + client.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reconfigure_successful(hass: HomeAssistant, client) -> None: + """Test that the reconfigure is successful.""" + entry = await setup_webostv(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "new_host"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "new_host" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (WebOsTvPairError, "error_pairing"), + (ConnectionRefusedError, "cannot_connect"), + ], +) +async def test_reconfigure_errors( + hass: HomeAssistant, client, side_effect, error +) -> None: + """Test reconfigure errors.""" + entry = await setup_webostv(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + client.connect.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "new_host"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + client.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "new_host"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_wrong_device(hass: HomeAssistant, client) -> None: + """Test abort if reconfigure host is wrong webOS TV device.""" + entry = await setup_webostv(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + client.hello_info = {"deviceUUID": "wrong_uuid"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "new_host"}, + ) + assert result["type"] is FlowResultType.ABORT - assert result["reason"] == reason + assert result["reason"] == "wrong_device" diff --git a/tests/components/webostv/test_init.py b/tests/components/webostv/test_init.py index ba755d80b30c5f..cd8f443c8fdb74 100644 --- a/tests/components/webostv/test_init.py +++ b/tests/components/webostv/test_init.py @@ -1,9 +1,6 @@ """The tests for the LG webOS TV platform.""" -from unittest.mock import Mock - from aiowebostv import WebOsTvPairError -import pytest from homeassistant.components.media_player import ATTR_INPUT_SOURCE_LIST from homeassistant.components.webostv.const import CONF_SOURCES, DOMAIN @@ -15,12 +12,10 @@ from .const import ENTITY_ID -async def test_reauth_setup_entry( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_reauth_setup_entry(hass: HomeAssistant, client) -> None: """Test reauth flow triggered by setup entry.""" - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) - monkeypatch.setattr(client, "connect", Mock(side_effect=WebOsTvPairError)) + client.is_connected.return_value = False + client.connect.side_effect = WebOsTvPairError entry = await setup_webostv(hass) assert entry.state is ConfigEntryState.SETUP_ERROR @@ -37,11 +32,9 @@ async def test_reauth_setup_entry( assert flow["context"].get("entry_id") == entry.entry_id -async def test_key_update_setup_entry( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_key_update_setup_entry(hass: HomeAssistant, client) -> None: """Test key update from setup entry.""" - monkeypatch.setattr(client, "client_key", "new_key") + client.client_key = "new_key" entry = await setup_webostv(hass) assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/webostv/test_notify.py b/tests/components/webostv/test_notify.py index 2f29281a49611a..b12cd0c7c6cfa0 100644 --- a/tests/components/webostv/test_notify.py +++ b/tests/components/webostv/test_notify.py @@ -1,6 +1,6 @@ """The tests for the WebOS TV notify platform.""" -from unittest.mock import Mock, call +from unittest.mock import call from aiowebostv import WebOsTvPairError import pytest @@ -74,14 +74,12 @@ async def test_notify(hass: HomeAssistant, client) -> None: ) -async def test_notify_not_connected( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_notify_not_connected(hass: HomeAssistant, client) -> None: """Test sending a message when client is not connected.""" await setup_webostv(hass) assert hass.services.has_service(NOTIFY_DOMAIN, SERVICE_NAME) - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) + client.is_connected.return_value = False await hass.services.async_call( NOTIFY_DOMAIN, SERVICE_NAME, @@ -99,16 +97,13 @@ async def test_notify_not_connected( async def test_icon_not_found( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - client, - monkeypatch: pytest.MonkeyPatch, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, client ) -> None: """Test notify icon not found error.""" await setup_webostv(hass) assert hass.services.has_service(NOTIFY_DOMAIN, SERVICE_NAME) - monkeypatch.setattr(client, "send_message", Mock(side_effect=FileNotFoundError)) + client.send_message.side_effect = FileNotFoundError await hass.services.async_call( NOTIFY_DOMAIN, SERVICE_NAME, @@ -134,19 +129,14 @@ async def test_icon_not_found( ], ) async def test_connection_errors( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - client, - monkeypatch: pytest.MonkeyPatch, - side_effect, - error, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, client, side_effect, error ) -> None: """Test connection errors scenarios.""" await setup_webostv(hass) assert hass.services.has_service("notify", SERVICE_NAME) - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) - monkeypatch.setattr(client, "connect", Mock(side_effect=side_effect)) + client.is_connected.return_value = False + client.connect.side_effect = side_effect await hass.services.async_call( NOTIFY_DOMAIN, SERVICE_NAME, @@ -159,7 +149,7 @@ async def test_connection_errors( blocking=True, ) assert client.mock_calls[0] == call.connect() - assert client.connect.call_count == 1 + assert client.connect.call_count == 2 client.send_message.assert_not_called() assert error in caplog.text