diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index a28c948d28b35..636417dd43b6d 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -311,11 +311,13 @@ def _create_token_stats( class AnthropicBaseLLMEntity(Entity): """Anthropic base LLM entity.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the entity.""" self.entry = entry self.subentry = subentry - self._attr_name = subentry.title self._attr_unique_id = subentry.subentry_id self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, subentry.subentry_id)}, diff --git a/homeassistant/components/blue_current/__init__.py b/homeassistant/components/blue_current/__init__.py index 775ca16a12a8c..eeda91a70a310 100644 --- a/homeassistant/components/blue_current/__init__.py +++ b/homeassistant/components/blue_current/__init__.py @@ -15,23 +15,31 @@ ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_NAME, CONF_API_TOKEN, Platform +from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import DOMAIN, EVSE_ID, LOGGER, MODEL_TYPE +from .const import ( + CHARGEPOINT_SETTINGS, + CHARGEPOINT_STATUS, + DOMAIN, + EVSE_ID, + LOGGER, + PLUG_AND_CHARGE, + VALUE, +) type BlueCurrentConfigEntry = ConfigEntry[Connector] -PLATFORMS = [Platform.BUTTON, Platform.SENSOR] +PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] CHARGE_POINTS = "CHARGE_POINTS" DATA = "data" DELAY = 5 GRID = "GRID" OBJECT = "object" -VALUE_TYPES = ["CH_STATUS"] +VALUE_TYPES = [CHARGEPOINT_STATUS, CHARGEPOINT_SETTINGS] async def async_setup_entry( @@ -94,7 +102,7 @@ async def on_data(self, message: dict) -> None: elif object_name in VALUE_TYPES: value_data: dict = message[DATA] evse_id = value_data.pop(EVSE_ID) - self.update_charge_point(evse_id, value_data) + self.update_charge_point(evse_id, object_name, value_data) # gets grid key / values elif GRID in object_name: @@ -106,26 +114,37 @@ async def handle_charge_point_data(self, charge_points_data: list) -> None: """Handle incoming chargepoint data.""" await asyncio.gather( *( - self.handle_charge_point( - entry[EVSE_ID], entry[MODEL_TYPE], entry[ATTR_NAME] - ) + self.handle_charge_point(entry[EVSE_ID], entry) for entry in charge_points_data ), self.client.get_grid_status(charge_points_data[0][EVSE_ID]), ) - async def handle_charge_point(self, evse_id: str, model: str, name: str) -> None: + async def handle_charge_point( + self, evse_id: str, charge_point: dict[str, Any] + ) -> None: """Add the chargepoint and request their data.""" - self.add_charge_point(evse_id, model, name) + self.add_charge_point(evse_id, charge_point) await self.client.get_status(evse_id) - def add_charge_point(self, evse_id: str, model: str, name: str) -> None: + def add_charge_point(self, evse_id: str, charge_point: dict[str, Any]) -> None: """Add a charge point to charge_points.""" - self.charge_points[evse_id] = {MODEL_TYPE: model, ATTR_NAME: name} + self.charge_points[evse_id] = charge_point - def update_charge_point(self, evse_id: str, data: dict) -> None: + def update_charge_point(self, evse_id: str, update_type: str, data: dict) -> None: """Update the charge point data.""" - self.charge_points[evse_id].update(data) + charge_point = self.charge_points[evse_id] + if update_type == CHARGEPOINT_SETTINGS: + # Update the plug and charge object. The library parses this object to a bool instead of an object. + plug_and_charge = charge_point.get(PLUG_AND_CHARGE) + if plug_and_charge is not None: + plug_and_charge[VALUE] = data[PLUG_AND_CHARGE] + + # Remove the plug and charge object from the data list before updating. + del data[PLUG_AND_CHARGE] + + charge_point.update(data) + self.dispatch_charge_point_update_signal(evse_id) def dispatch_charge_point_update_signal(self, evse_id: str) -> None: diff --git a/homeassistant/components/blue_current/const.py b/homeassistant/components/blue_current/const.py index 008e6efa87228..33e0e8b1176a8 100644 --- a/homeassistant/components/blue_current/const.py +++ b/homeassistant/components/blue_current/const.py @@ -8,3 +8,14 @@ EVSE_ID = "evse_id" MODEL_TYPE = "model_type" +PLUG_AND_CHARGE = "plug_and_charge" +VALUE = "value" +PERMISSION = "permission" +CHARGEPOINT_STATUS = "CH_STATUS" +CHARGEPOINT_SETTINGS = "CH_SETTINGS" +BLOCK = "block" +UNAVAILABLE = "unavailable" +AVAILABLE = "available" +LINKED_CHARGE_CARDS = "linked_charge_cards_only" +PUBLIC_CHARGING = "public_charging" +ACTIVITY = "activity" diff --git a/homeassistant/components/blue_current/icons.json b/homeassistant/components/blue_current/icons.json index ce936902e9182..28d4acbc1d801 100644 --- a/homeassistant/components/blue_current/icons.json +++ b/homeassistant/components/blue_current/icons.json @@ -30,6 +30,17 @@ "stop_charge_session": { "default": "mdi:stop" } + }, + "switch": { + "plug_and_charge": { + "default": "mdi:ev-plug-type2" + }, + "linked_charge_cards": { + "default": "mdi:account-group" + }, + "block": { + "default": "mdi:lock" + } } } } diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 28eb20fa912b0..0a99af603ccae 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -124,6 +124,17 @@ "reset": { "name": "Reset" } + }, + "switch": { + "plug_and_charge": { + "name": "Plug & Charge" + }, + "linked_charge_cards_only": { + "name": "Linked charging cards only" + }, + "block": { + "name": "Block charge point" + } } } } diff --git a/homeassistant/components/blue_current/switch.py b/homeassistant/components/blue_current/switch.py new file mode 100644 index 0000000000000..a0848387901b5 --- /dev/null +++ b/homeassistant/components/blue_current/switch.py @@ -0,0 +1,169 @@ +"""Support for Blue Current switches.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import PLUG_AND_CHARGE, BlueCurrentConfigEntry, Connector +from .const import ( + AVAILABLE, + BLOCK, + LINKED_CHARGE_CARDS, + PUBLIC_CHARGING, + UNAVAILABLE, + VALUE, +) +from .entity import ChargepointEntity + + +@dataclass(kw_only=True, frozen=True) +class BlueCurrentSwitchEntityDescription(SwitchEntityDescription): + """Describes a Blue Current switch entity.""" + + function: Callable[[Connector, str, bool], Any] + + turn_on_off_fn: Callable[[str, Connector], tuple[bool, bool]] + """Update the switch based on the latest data received from the websocket. The first returned boolean is _attr_is_on, the second one has_value.""" + + +def update_on_value_and_activity( + key: str, evse_id: str, connector: Connector, reverse_is_on: bool = False +) -> tuple[bool, bool]: + """Return the updated state of the switch based on received chargepoint data and activity.""" + + data_object = connector.charge_points[evse_id].get(key) + is_on = data_object[VALUE] if data_object is not None else None + activity = connector.charge_points[evse_id].get("activity") + + if is_on is not None and activity == AVAILABLE: + return is_on if not reverse_is_on else not is_on, True + return False, False + + +def update_block_switch(evse_id: str, connector: Connector) -> tuple[bool, bool]: + """Return the updated data for a block switch.""" + activity = connector.charge_points[evse_id].get("activity") + return activity == UNAVAILABLE, activity in [AVAILABLE, UNAVAILABLE] + + +def update_charge_point( + key: str, evse_id: str, connector: Connector, new_switch_value: bool +) -> None: + """Change charge point data when the state of the switch changes.""" + data_objects = connector.charge_points[evse_id].get(key) + if data_objects is not None: + data_objects[VALUE] = new_switch_value + + +async def set_plug_and_charge(connector: Connector, evse_id: str, value: bool) -> None: + """Toggle the plug and charge setting for a specific charging point.""" + await connector.client.set_plug_and_charge(evse_id, value) + update_charge_point(PLUG_AND_CHARGE, evse_id, connector, value) + + +async def set_linked_charge_cards( + connector: Connector, evse_id: str, value: bool +) -> None: + """Toggle the plug and charge setting for a specific charging point.""" + await connector.client.set_linked_charge_cards_only(evse_id, value) + update_charge_point(PUBLIC_CHARGING, evse_id, connector, not value) + + +SWITCHES = ( + BlueCurrentSwitchEntityDescription( + key=PLUG_AND_CHARGE, + translation_key=PLUG_AND_CHARGE, + function=set_plug_and_charge, + turn_on_off_fn=lambda evse_id, connector: ( + update_on_value_and_activity(PLUG_AND_CHARGE, evse_id, connector) + ), + ), + BlueCurrentSwitchEntityDescription( + key=LINKED_CHARGE_CARDS, + translation_key=LINKED_CHARGE_CARDS, + function=set_linked_charge_cards, + turn_on_off_fn=lambda evse_id, connector: ( + update_on_value_and_activity( + PUBLIC_CHARGING, evse_id, connector, reverse_is_on=True + ) + ), + ), + BlueCurrentSwitchEntityDescription( + key=BLOCK, + translation_key=BLOCK, + function=lambda connector, evse_id, value: connector.client.block( + evse_id, value + ), + turn_on_off_fn=update_block_switch, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BlueCurrentConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Blue Current switches.""" + connector = entry.runtime_data + + async_add_entities( + ChargePointSwitch( + connector, + evse_id, + switch, + ) + for evse_id in connector.charge_points + for switch in SWITCHES + ) + + +class ChargePointSwitch(ChargepointEntity, SwitchEntity): + """Base charge point switch.""" + + has_value = True + entity_description: BlueCurrentSwitchEntityDescription + + def __init__( + self, + connector: Connector, + evse_id: str, + switch: BlueCurrentSwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(connector, evse_id) + + self.key = switch.key + self.entity_description = switch + self.evse_id = evse_id + self._attr_available = True + self._attr_unique_id = f"{switch.key}_{evse_id}" + + async def call_function(self, value: bool) -> None: + """Call the function to set setting.""" + await self.entity_description.function(self.connector, self.evse_id, value) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.call_function(True) + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.call_function(False) + self._attr_is_on = False + self.async_write_ha_state() + + @callback + def update_from_latest_data(self) -> None: + """Fetch new state data for the switch.""" + new_state = self.entity_description.turn_on_off_fn(self.evse_id, self.connector) + self._attr_is_on = new_state[0] + self.has_value = new_state[1] diff --git a/homeassistant/components/dominos/strings.json b/homeassistant/components/dominos/strings.json index 0ceabd7abe8c4..5d95be478ce0b 100644 --- a/homeassistant/components/dominos/strings.json +++ b/homeassistant/components/dominos/strings.json @@ -2,11 +2,11 @@ "services": { "order": { "name": "Order", - "description": "Places a set of orders with Dominos Pizza.", + "description": "Places a set of orders with Domino's Pizza.", "fields": { "order_entity_id": { "name": "Order entity", - "description": "The ID (as specified in the configuration) of an order to place. If provided as an array, all of the identified orders will be placed." + "description": "The ID (as specified in the configuration) of an order to place. If provided as an array, all the identified orders will be placed." } } } diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json index dba4b6d563ce4..d414b559aa15c 100644 --- a/homeassistant/components/eheimdigital/manifest.json +++ b/homeassistant/components/eheimdigital/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["eheimdigital"], - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["eheimdigital==1.3.0"], "zeroconf": [ { "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." } diff --git a/homeassistant/components/eheimdigital/quality_scale.yaml b/homeassistant/components/eheimdigital/quality_scale.yaml index 801e07483100b..96fa798f9cf09 100644 --- a/homeassistant/components/eheimdigital/quality_scale.yaml +++ b/homeassistant/components/eheimdigital/quality_scale.yaml @@ -46,22 +46,24 @@ rules: diagnostics: done discovery-update-info: done discovery: done - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo - docs-use-cases: todo + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done entity-translations: done exception-translations: done - icon-translations: todo + icon-translations: done reconfiguration-flow: done - repair-issues: todo + repair-issues: + status: exempt + comment: No repairs. stale-devices: done # Platinum diff --git a/homeassistant/components/fujitsu_fglair/climate.py b/homeassistant/components/fujitsu_fglair/climate.py index bf1df07823c0b..85ef119a5831e 100644 --- a/homeassistant/components/fujitsu_fglair/climate.py +++ b/homeassistant/components/fujitsu_fglair/climate.py @@ -15,6 +15,7 @@ FAN_HIGH, FAN_LOW, FAN_MEDIUM, + FAN_OFF, SWING_BOTH, SWING_HORIZONTAL, SWING_OFF, @@ -31,6 +32,7 @@ from .entity import FGLairEntity HA_TO_FUJI_FAN = { + FAN_OFF: FanSpeed.QUIET, FAN_LOW: FanSpeed.LOW, FAN_MEDIUM: FanSpeed.MEDIUM, FAN_HIGH: FanSpeed.HIGH, diff --git a/homeassistant/components/google_generative_ai_conversation/stt.py b/homeassistant/components/google_generative_ai_conversation/stt.py index bdf8a2fd7bfe0..f9b91ff66853a 100644 --- a/homeassistant/components/google_generative_ai_conversation/stt.py +++ b/homeassistant/components/google_generative_ai_conversation/stt.py @@ -53,131 +53,133 @@ def supported_languages(self) -> list[str]: """Return a list of supported languages.""" return [ "af-ZA", - "sq-AL", "am-ET", - "ar-DZ", + "ar-AE", "ar-BH", + "ar-DZ", "ar-EG", - "ar-IQ", "ar-IL", + "ar-IQ", "ar-JO", "ar-KW", "ar-LB", "ar-MA", "ar-OM", + "ar-PS", "ar-QA", "ar-SA", - "ar-PS", "ar-TN", - "ar-AE", "ar-YE", - "hy-AM", "az-AZ", - "eu-ES", + "bg-BG", "bn-BD", "bn-IN", "bs-BA", - "bg-BG", - "my-MM", "ca-ES", - "zh-CN", - "zh-TW", - "hr-HR", "cs-CZ", "da-DK", - "nl-BE", - "nl-NL", + "de-AT", + "de-CH", + "de-DE", + "el-GR", "en-AU", "en-CA", + "en-GB", "en-GH", "en-HK", - "en-IN", "en-IE", + "en-IN", "en-KE", - "en-NZ", "en-NG", - "en-PK", + "en-NZ", "en-PH", + "en-PK", "en-SG", - "en-ZA", "en-TZ", - "en-GB", "en-US", + "en-ZA", + "es-AR", + "es-BO", + "es-CL", + "es-CO", + "es-CR", + "es-DO", + "es-EC", + "es-ES", + "es-GT", + "es-HN", + "es-MX", + "es-NI", + "es-PA", + "es-PE", + "es-PR", + "es-PY", + "es-SV", + "es-US", + "es-UY", + "es-VE", "et-EE", - "fil-PH", + "eu-ES", + "fa-IR", "fi-FI", + "fil-PH", "fr-BE", "fr-CA", - "fr-FR", "fr-CH", + "fr-FR", + "ga-IE", "gl-ES", - "ka-GE", - "de-AT", - "de-DE", - "de-CH", - "el-GR", "gu-IN", - "iw-IL", + "he-IL", "hi-IN", + "hr-HR", "hu-HU", - "is-IS", + "hy-AM", "id-ID", - "it-IT", + "is-IS", "it-CH", + "it-IT", + "iw-IL", "ja-JP", "jv-ID", - "kn-IN", + "ka-GE", "kk-KZ", "km-KH", + "kn-IN", "ko-KR", + "lb-LU", "lo-LA", - "lv-LV", "lt-LT", + "lv-LV", "mk-MK", - "ms-MY", "ml-IN", - "mr-IN", "mn-MN", + "mr-IN", + "ms-MY", + "my-MM", + "nb-NO", "ne-NP", + "nl-BE", + "nl-NL", "no-NO", - "fa-IR", "pl-PL", "pt-BR", "pt-PT", "ro-RO", "ru-RU", - "sr-RS", "si-LK", "sk-SK", "sl-SI", - "es-AR", - "es-BO", - "es-CL", - "es-CO", - "es-CR", - "es-DO", - "es-EC", - "es-SV", - "es-GT", - "es-HN", - "es-MX", - "es-NI", - "es-PA", - "es-PY", - "es-PE", - "es-PR", - "es-ES", - "es-US", - "es-UY", - "es-VE", + "sq-AL", + "sr-RS", "su-ID", + "sv-SE", "sw-KE", "sw-TZ", - "sv-SE", "ta-IN", + "ta-LK", "ta-MY", "ta-SG", - "ta-LK", "te-IN", "th-TH", "tr-TR", @@ -186,6 +188,9 @@ def supported_languages(self) -> list[str]: "ur-PK", "uz-UZ", "vi-VN", + "zh-CN", + "zh-HK", + "zh-TW", "zu-ZA", ] diff --git a/homeassistant/components/google_generative_ai_conversation/tts.py b/homeassistant/components/google_generative_ai_conversation/tts.py index 9bc5b0c6cb673..08e83242fcdc8 100644 --- a/homeassistant/components/google_generative_ai_conversation/tts.py +++ b/homeassistant/components/google_generative_ai_conversation/tts.py @@ -48,10 +48,13 @@ class GoogleGenerativeAITextToSpeechEntity( _attr_supported_options = [ATTR_VOICE] # See https://ai.google.dev/gemini-api/docs/speech-generation#languages + # Note the documentation might not be up to date, e.g. el-GR is not listed + # there but is supported. _attr_supported_languages = [ "ar-EG", "bn-BD", "de-DE", + "el-GR", "en-IN", "en-US", "es-US", diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py index 66df76bc6cba5..3927078878067 100644 --- a/homeassistant/components/growatt_server/__init__.py +++ b/homeassistant/components/growatt_server/__init__.py @@ -1,21 +1,104 @@ """The Growatt server PV inverter sensor integration.""" -from homeassistant import config_entries -from homeassistant.config_entries import ConfigEntry +from collections.abc import Mapping + +import growattServer + +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError + +from .const import ( + CONF_PLANT_ID, + DEFAULT_PLANT_ID, + DEFAULT_URL, + DEPRECATED_URLS, + LOGIN_INVALID_AUTH_CODE, + PLATFORMS, +) +from .coordinator import GrowattConfigEntry, GrowattCoordinator +from .models import GrowattRuntimeData + -from .const import PLATFORMS +def get_device_list( + api: growattServer.GrowattApi, config: Mapping[str, str] +) -> tuple[list[dict[str, str]], str]: + """Retrieve the device list for the selected plant.""" + plant_id = config[CONF_PLANT_ID] + + # Log in to api and fetch first plant if no plant id is defined. + login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD]) + if ( + not login_response["success"] + and login_response["msg"] == LOGIN_INVALID_AUTH_CODE + ): + raise ConfigEntryError("Username, Password or URL may be incorrect!") + user_id = login_response["user"]["id"] + if plant_id == DEFAULT_PLANT_ID: + plant_info = api.plant_list(user_id) + plant_id = plant_info["data"][0]["plantId"] + + # Get a list of devices for specified plant to add sensors for. + devices = api.device_list(plant_id) + return devices, plant_id async def async_setup_entry( - hass: HomeAssistant, entry: config_entries.ConfigEntry + hass: HomeAssistant, config_entry: GrowattConfigEntry ) -> bool: - """Load the saved entities.""" + """Set up Growatt from a config entry.""" + config = config_entry.data + username = config[CONF_USERNAME] + url = config.get(CONF_URL, DEFAULT_URL) + + # If the URL has been deprecated then change to the default instead + if url in DEPRECATED_URLS: + url = DEFAULT_URL + new_data = dict(config_entry.data) + new_data[CONF_URL] = url + hass.config_entries.async_update_entry(config_entry, data=new_data) + + # Initialise the library with the username & a random id each time it is started + api = growattServer.GrowattApi(add_random_user_id=True, agent_identifier=username) + api.server_url = url + + devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config) + + # Create a coordinator for the total sensors + total_coordinator = GrowattCoordinator( + hass, config_entry, plant_id, "total", plant_id + ) + + # Create coordinators for each device + device_coordinators = { + device["deviceSn"]: GrowattCoordinator( + hass, config_entry, device["deviceSn"], device["deviceType"], plant_id + ) + for device in devices + if device["deviceType"] in ["inverter", "tlx", "storage", "mix"] + } + + # Perform the first refresh for the total coordinator + await total_coordinator.async_config_entry_first_refresh() + + # Perform the first refresh for each device coordinator + for device_coordinator in device_coordinators.values(): + await device_coordinator.async_config_entry_first_refresh() + + # Store runtime data in the config entry + config_entry.runtime_data = GrowattRuntimeData( + total_coordinator=total_coordinator, + devices=device_coordinators, + ) + + # Set up all the entities + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: GrowattConfigEntry +) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/growatt_server/coordinator.py b/homeassistant/components/growatt_server/coordinator.py new file mode 100644 index 0000000000000..a1a2fb938f0f4 --- /dev/null +++ b/homeassistant/components/growatt_server/coordinator.py @@ -0,0 +1,210 @@ +"""Coordinator module for managing Growatt data fetching.""" + +import datetime +import json +import logging +from typing import TYPE_CHECKING, Any + +import growattServer + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import DEFAULT_URL, DOMAIN +from .models import GrowattRuntimeData + +if TYPE_CHECKING: + from .sensor.sensor_entity_description import GrowattSensorEntityDescription + +type GrowattConfigEntry = ConfigEntry[GrowattRuntimeData] + +SCAN_INTERVAL = datetime.timedelta(minutes=5) + +_LOGGER = logging.getLogger(__name__) + + +class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator to manage Growatt data fetching.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: GrowattConfigEntry, + device_id: str, + device_type: str, + plant_id: str, + ) -> None: + """Initialize the coordinator.""" + self.username = config_entry.data[CONF_USERNAME] + self.password = config_entry.data[CONF_PASSWORD] + self.url = config_entry.data.get(CONF_URL, DEFAULT_URL) + self.api = growattServer.GrowattApi( + add_random_user_id=True, agent_identifier=self.username + ) + + # Set server URL + self.api.server_url = self.url + + self.device_id = device_id + self.device_type = device_type + self.plant_id = plant_id + + # Initialize previous_values to store historical data + self.previous_values: dict[str, Any] = {} + + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN} ({device_id})", + update_interval=SCAN_INTERVAL, + config_entry=config_entry, + ) + + def _sync_update_data(self) -> dict[str, Any]: + """Update data via library synchronously.""" + _LOGGER.debug("Updating data for %s (%s)", self.device_id, self.device_type) + + # Login in to the Growatt server + self.api.login(self.username, self.password) + + if self.device_type == "total": + total_info = self.api.plant_info(self.device_id) + del total_info["deviceList"] + plant_money_text, currency = total_info["plantMoneyText"].split("/") + total_info["plantMoneyText"] = plant_money_text + total_info["currency"] = currency + self.data = total_info + elif self.device_type == "inverter": + self.data = self.api.inverter_detail(self.device_id) + elif self.device_type == "tlx": + tlx_info = self.api.tlx_detail(self.device_id) + self.data = tlx_info["data"] + elif self.device_type == "storage": + storage_info_detail = self.api.storage_params(self.device_id) + storage_energy_overview = self.api.storage_energy_overview( + self.plant_id, self.device_id + ) + self.data = { + **storage_info_detail["storageDetailBean"], + **storage_energy_overview, + } + elif self.device_type == "mix": + mix_info = self.api.mix_info(self.device_id) + mix_totals = self.api.mix_totals(self.device_id, self.plant_id) + mix_system_status = self.api.mix_system_status( + self.device_id, self.plant_id + ) + mix_detail = self.api.mix_detail(self.device_id, self.plant_id) + + # Get the chart data and work out the time of the last entry + mix_chart_entries = mix_detail["chartData"] + sorted_keys = sorted(mix_chart_entries) + + # Create datetime from the latest entry + date_now = dt_util.now().date() + last_updated_time = dt_util.parse_time(str(sorted_keys[-1])) + mix_detail["lastdataupdate"] = datetime.datetime.combine( + date_now, + last_updated_time, # type: ignore[arg-type] + dt_util.get_default_time_zone(), + ) + + # Dashboard data for mix system + dashboard_data = self.api.dashboard_data(self.plant_id) + dashboard_values_for_mix = { + "etouser_combined": float(dashboard_data["etouser"].replace("kWh", "")) + } + self.data = { + **mix_info, + **mix_totals, + **mix_system_status, + **mix_detail, + **dashboard_values_for_mix, + } + _LOGGER.debug( + "Finished updating data for %s (%s)", + self.device_id, + self.device_type, + ) + + return self.data + + async def _async_update_data(self) -> dict[str, Any]: + """Asynchronously update data via library.""" + try: + return await self.hass.async_add_executor_job(self._sync_update_data) + except json.decoder.JSONDecodeError as err: + _LOGGER.error("Unable to fetch data from Growatt server: %s", err) + raise UpdateFailed(f"Error fetching data: {err}") from err + + def get_currency(self): + """Get the currency.""" + return self.data.get("currency") + + def get_data( + self, entity_description: "GrowattSensorEntityDescription" + ) -> str | int | float | None: + """Get the data.""" + variable = entity_description.api_key + api_value = self.data.get(variable) + previous_value = self.previous_values.get(variable) + return_value = api_value + + # If we have a 'drop threshold' specified, then check it and correct if needed + if ( + entity_description.previous_value_drop_threshold is not None + and previous_value is not None + and api_value is not None + ): + _LOGGER.debug( + ( + "%s - Drop threshold specified (%s), checking for drop... API" + " Value: %s, Previous Value: %s" + ), + entity_description.name, + entity_description.previous_value_drop_threshold, + api_value, + previous_value, + ) + diff = float(api_value) - float(previous_value) + + # Check if the value has dropped (negative value i.e. < 0) and it has only + # dropped by a small amount, if so, use the previous value. + # Note - The energy dashboard takes care of drops within 10% + # of the current value, however if the value is low e.g. 0.2 + # and drops by 0.1 it classes as a reset. + if -(entity_description.previous_value_drop_threshold) <= diff < 0: + _LOGGER.debug( + ( + "Diff is negative, but only by a small amount therefore not a" + " nightly reset, using previous value (%s) instead of api value" + " (%s)" + ), + previous_value, + api_value, + ) + return_value = previous_value + else: + _LOGGER.debug( + "%s - No drop detected, using API value", entity_description.name + ) + + # Lifetime total values should always be increasing, they will never reset, + # however the API sometimes returns 0 values when the clock turns to 00:00 + # local time in that scenario we should just return the previous value + if entity_description.never_resets and api_value == 0 and previous_value: + _LOGGER.debug( + ( + "API value is 0, but this value should never reset, returning" + " previous value (%s) instead" + ), + previous_value, + ) + return_value = previous_value + + self.previous_values[variable] = return_value + + return return_value diff --git a/homeassistant/components/growatt_server/models.py b/homeassistant/components/growatt_server/models.py new file mode 100644 index 0000000000000..8c5f409616ace --- /dev/null +++ b/homeassistant/components/growatt_server/models.py @@ -0,0 +1,17 @@ +"""Models for the Growatt server integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .coordinator import GrowattCoordinator + + +@dataclass +class GrowattRuntimeData: + """Runtime data for the Growatt integration.""" + + total_coordinator: GrowattCoordinator + devices: dict[str, GrowattCoordinator] diff --git a/homeassistant/components/growatt_server/sensor/__init__.py b/homeassistant/components/growatt_server/sensor/__init__.py index 2794403811d5c..3a78f26f09179 100644 --- a/homeassistant/components/growatt_server/sensor/__init__.py +++ b/homeassistant/components/growatt_server/sensor/__init__.py @@ -2,29 +2,16 @@ from __future__ import annotations -import datetime -import json import logging -import growattServer - from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util import Throttle, dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from ..const import ( - CONF_PLANT_ID, - DEFAULT_PLANT_ID, - DEFAULT_URL, - DEPRECATED_URLS, - DOMAIN, - LOGIN_INVALID_AUTH_CODE, -) +from ..const import DOMAIN +from ..coordinator import GrowattConfigEntry, GrowattCoordinator from .inverter import INVERTER_SENSOR_TYPES from .mix import MIX_SENSOR_TYPES from .sensor_entity_description import GrowattSensorEntityDescription @@ -34,136 +21,97 @@ _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = datetime.timedelta(minutes=5) - - -def get_device_list(api, config): - """Retrieve the device list for the selected plant.""" - plant_id = config[CONF_PLANT_ID] - - # Log in to api and fetch first plant if no plant id is defined. - login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD]) - if ( - not login_response["success"] - and login_response["msg"] == LOGIN_INVALID_AUTH_CODE - ): - raise ConfigEntryError("Username, Password or URL may be incorrect!") - user_id = login_response["user"]["id"] - if plant_id == DEFAULT_PLANT_ID: - plant_info = api.plant_list(user_id) - plant_id = plant_info["data"][0]["plantId"] - - # Get a list of devices for specified plant to add sensors for. - devices = api.device_list(plant_id) - return [devices, plant_id] - async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GrowattConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Growatt sensor.""" - config = {**config_entry.data} - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - url = config.get(CONF_URL, DEFAULT_URL) - name = config[CONF_NAME] - - # If the URL has been deprecated then change to the default instead - if url in DEPRECATED_URLS: - _LOGGER.warning( - "URL: %s has been deprecated, migrating to the latest default: %s", - url, - DEFAULT_URL, - ) - url = DEFAULT_URL - config[CONF_URL] = url - hass.config_entries.async_update_entry(config_entry, data=config) - - # Initialise the library with the username & a random id each time it is started - api = growattServer.GrowattApi(add_random_user_id=True, agent_identifier=username) - api.server_url = url - - devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config) - - probe = GrowattData(api, username, password, plant_id, "total") - entities = [ - GrowattInverter( - probe, - name=f"{name} Total", - unique_id=f"{plant_id}-{description.key}", + # Use runtime_data instead of hass.data + data = config_entry.runtime_data + + entities: list[GrowattSensor] = [] + + # Add total sensors + total_coordinator = data.total_coordinator + entities.extend( + GrowattSensor( + total_coordinator, + name=f"{config_entry.data['name']} Total", + serial_id=config_entry.data["plant_id"], + unique_id=f"{config_entry.data['plant_id']}-{description.key}", description=description, ) for description in TOTAL_SENSOR_TYPES - ] - - # Add sensors for each device in the specified plant. - for device in devices: - probe = GrowattData( - api, username, password, device["deviceSn"], device["deviceType"] - ) - sensor_descriptions: tuple[GrowattSensorEntityDescription, ...] = () - if device["deviceType"] == "inverter": - sensor_descriptions = INVERTER_SENSOR_TYPES - elif device["deviceType"] == "tlx": - probe.plant_id = plant_id - sensor_descriptions = TLX_SENSOR_TYPES - elif device["deviceType"] == "storage": - probe.plant_id = plant_id - sensor_descriptions = STORAGE_SENSOR_TYPES - elif device["deviceType"] == "mix": - probe.plant_id = plant_id - sensor_descriptions = MIX_SENSOR_TYPES + ) + + # Add sensors for each device + for device_sn, device_coordinator in data.devices.items(): + sensor_descriptions: list = [] + if device_coordinator.device_type == "inverter": + sensor_descriptions = list(INVERTER_SENSOR_TYPES) + elif device_coordinator.device_type == "tlx": + sensor_descriptions = list(TLX_SENSOR_TYPES) + elif device_coordinator.device_type == "storage": + sensor_descriptions = list(STORAGE_SENSOR_TYPES) + elif device_coordinator.device_type == "mix": + sensor_descriptions = list(MIX_SENSOR_TYPES) else: _LOGGER.debug( "Device type %s was found but is not supported right now", - device["deviceType"], + device_coordinator.device_type, ) entities.extend( - [ - GrowattInverter( - probe, - name=f"{device['deviceAilas']}", - unique_id=f"{device['deviceSn']}-{description.key}", - description=description, - ) - for description in sensor_descriptions - ] + GrowattSensor( + device_coordinator, + name=device_sn, + serial_id=device_sn, + unique_id=f"{device_sn}-{description.key}", + description=description, + ) + for description in sensor_descriptions ) - async_add_entities(entities, True) + async_add_entities(entities) -class GrowattInverter(SensorEntity): +class GrowattSensor(CoordinatorEntity[GrowattCoordinator], SensorEntity): """Representation of a Growatt Sensor.""" _attr_has_entity_name = True - entity_description: GrowattSensorEntityDescription def __init__( - self, probe, name, unique_id, description: GrowattSensorEntityDescription + self, + coordinator: GrowattCoordinator, + name: str, + serial_id: str, + unique_id: str, + description: GrowattSensorEntityDescription, ) -> None: """Initialize a PVOutput sensor.""" - self.probe = probe + super().__init__(coordinator) self.entity_description = description self._attr_unique_id = unique_id self._attr_icon = "mdi:solar-power" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, probe.device_id)}, + identifiers={(DOMAIN, serial_id)}, manufacturer="Growatt", name=name, ) @property - def native_value(self): + def native_value(self) -> str | int | float | None: """Return the state of the sensor.""" - result = self.probe.get_data(self.entity_description) - if self.entity_description.precision is not None: + result = self.coordinator.get_data(self.entity_description) + if ( + isinstance(result, (int, float)) + and self.entity_description.precision is not None + ): result = round(result, self.entity_description.precision) return result @@ -171,182 +119,5 @@ def native_value(self): def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of the sensor, if any.""" if self.entity_description.currency: - return self.probe.get_currency() + return self.coordinator.get_currency() return super().native_unit_of_measurement - - def update(self) -> None: - """Get the latest data from the Growat API and updates the state.""" - self.probe.update() - - -class GrowattData: - """The class for handling data retrieval.""" - - def __init__(self, api, username, password, device_id, growatt_type): - """Initialize the probe.""" - - self.growatt_type = growatt_type - self.api = api - self.device_id = device_id - self.plant_id = None - self.data = {} - self.previous_values = {} - self.username = username - self.password = password - - @Throttle(SCAN_INTERVAL) - def update(self): - """Update probe data.""" - self.api.login(self.username, self.password) - _LOGGER.debug("Updating data for %s (%s)", self.device_id, self.growatt_type) - try: - if self.growatt_type == "total": - total_info = self.api.plant_info(self.device_id) - del total_info["deviceList"] - # PlantMoneyText comes in as "3.1/€" split between value and currency - plant_money_text, currency = total_info["plantMoneyText"].split("/") - total_info["plantMoneyText"] = plant_money_text - total_info["currency"] = currency - self.data = total_info - elif self.growatt_type == "inverter": - inverter_info = self.api.inverter_detail(self.device_id) - self.data = inverter_info - elif self.growatt_type == "tlx": - tlx_info = self.api.tlx_detail(self.device_id) - self.data = tlx_info["data"] - elif self.growatt_type == "storage": - storage_info_detail = self.api.storage_params(self.device_id)[ - "storageDetailBean" - ] - storage_energy_overview = self.api.storage_energy_overview( - self.plant_id, self.device_id - ) - self.data = {**storage_info_detail, **storage_energy_overview} - elif self.growatt_type == "mix": - mix_info = self.api.mix_info(self.device_id) - mix_totals = self.api.mix_totals(self.device_id, self.plant_id) - mix_system_status = self.api.mix_system_status( - self.device_id, self.plant_id - ) - - mix_detail = self.api.mix_detail(self.device_id, self.plant_id) - # Get the chart data and work out the time of the last entry, use this - # as the last time data was published to the Growatt Server - mix_chart_entries = mix_detail["chartData"] - sorted_keys = sorted(mix_chart_entries) - - # Create datetime from the latest entry - date_now = dt_util.now().date() - last_updated_time = dt_util.parse_time(str(sorted_keys[-1])) - mix_detail["lastdataupdate"] = datetime.datetime.combine( - date_now, last_updated_time, dt_util.get_default_time_zone() - ) - - # Dashboard data is largely inaccurate for mix system but it is the only - # call with the ability to return the combined imported from grid value - # that is the combination of charging AND load consumption - dashboard_data = self.api.dashboard_data(self.plant_id) - # Dashboard values have units e.g. "kWh" as part of their returned - # string, so we remove it - dashboard_values_for_mix = { - # etouser is already used by the results from 'mix_detail' so we - # rebrand it as 'etouser_combined' - "etouser_combined": float( - dashboard_data["etouser"].replace("kWh", "") - ) - } - self.data = { - **mix_info, - **mix_totals, - **mix_system_status, - **mix_detail, - **dashboard_values_for_mix, - } - _LOGGER.debug( - "Finished updating data for %s (%s)", - self.device_id, - self.growatt_type, - ) - except json.decoder.JSONDecodeError: - _LOGGER.error("Unable to fetch data from Growatt server") - - def get_currency(self): - """Get the currency.""" - return self.data.get("currency") - - def get_data(self, entity_description): - """Get the data.""" - _LOGGER.debug( - "Data request for: %s", - entity_description.name, - ) - variable = entity_description.api_key - api_value = self.data.get(variable) - previous_value = self.previous_values.get(variable) - return_value = api_value - - # If we have a 'drop threshold' specified, then check it and correct if needed - if ( - entity_description.previous_value_drop_threshold is not None - and previous_value is not None - and api_value is not None - ): - _LOGGER.debug( - ( - "%s - Drop threshold specified (%s), checking for drop... API" - " Value: %s, Previous Value: %s" - ), - entity_description.name, - entity_description.previous_value_drop_threshold, - api_value, - previous_value, - ) - diff = float(api_value) - float(previous_value) - - # Check if the value has dropped (negative value i.e. < 0) and it has only - # dropped by a small amount, if so, use the previous value. - # Note - The energy dashboard takes care of drops within 10% - # of the current value, however if the value is low e.g. 0.2 - # and drops by 0.1 it classes as a reset. - if -(entity_description.previous_value_drop_threshold) <= diff < 0: - _LOGGER.debug( - ( - "Diff is negative, but only by a small amount therefore not a" - " nightly reset, using previous value (%s) instead of api value" - " (%s)" - ), - previous_value, - api_value, - ) - return_value = previous_value - else: - _LOGGER.debug( - "%s - No drop detected, using API value", entity_description.name - ) - - # Lifetime total values should always be increasing, they will never reset, - # however the API sometimes returns 0 values when the clock turns to 00:00 - # local time in that scenario we should just return the previous value - # Scenarios: - # 1 - System has a genuine 0 value when it it first commissioned: - # - will return 0 until a non-zero value is registered - # 2 - System has been running fine but temporarily resets to 0 briefly - # at midnight: - # - will return the previous value - # 3 - HA is restarted during the midnight 'outage' - Not handled: - # - Previous value will not exist meaning 0 will be returned - # - This is an edge case that would be better handled by looking - # up the previous value of the entity from the recorder - if entity_description.never_resets and api_value == 0 and previous_value: - _LOGGER.debug( - ( - "API value is 0, but this value should never reset, returning" - " previous value (%s) instead" - ), - previous_value, - ) - return_value = previous_value - - self.previous_values[variable] = return_value - - return return_value diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index e34aa020c5a32..6d67b4b79c05c 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -238,7 +238,7 @@ "name": "OS Agent version" }, "apparmor_version": { - "name": "Apparmor version" + "name": "AppArmor version" }, "cpu_percent": { "name": "CPU percent" diff --git a/homeassistant/components/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py index 281669aad0473..8e58a309e59e5 100644 --- a/homeassistant/components/husqvarna_automower/button.py +++ b/homeassistant/components/husqvarna_automower/button.py @@ -14,11 +14,7 @@ from . import AutomowerConfigEntry from .coordinator import AutomowerDataUpdateCoordinator -from .entity import ( - AutomowerAvailableEntity, - _check_error_free, - handle_sending_exception, -) +from .entity import AutomowerControlEntity, handle_sending_exception _LOGGER = logging.getLogger(__name__) @@ -45,7 +41,6 @@ class AutomowerButtonEntityDescription(ButtonEntityDescription): AutomowerButtonEntityDescription( key="sync_clock", translation_key="sync_clock", - available_fn=_check_error_free, press_fn=lambda session, mower_id: session.commands.set_datetime(mower_id), ), ) @@ -71,7 +66,7 @@ def _async_add_new_devices(mower_ids: set[str]) -> None: _async_add_new_devices(set(coordinator.data)) -class AutomowerButtonEntity(AutomowerAvailableEntity, ButtonEntity): +class AutomowerButtonEntity(AutomowerControlEntity, ButtonEntity): """Defining the AutomowerButtonEntity.""" entity_description: AutomowerButtonEntityDescription diff --git a/homeassistant/components/husqvarna_automower/entity.py b/homeassistant/components/husqvarna_automower/entity.py index 3ccb098262fd5..99df51c7fe764 100644 --- a/homeassistant/components/husqvarna_automower/entity.py +++ b/homeassistant/components/husqvarna_automower/entity.py @@ -37,15 +37,6 @@ ] -@callback -def _check_error_free(mower_attributes: MowerAttributes) -> bool: - """Check if the mower has any errors.""" - return ( - mower_attributes.mower.state not in ERROR_STATES - or mower_attributes.mower.activity not in ERROR_ACTIVITIES - ) - - @callback def _work_area_translation_key(work_area_id: int, key: str) -> str: """Return the translation key.""" @@ -120,25 +111,20 @@ def available(self) -> bool: return super().available and self.mower_id in self.coordinator.data -class AutomowerAvailableEntity(AutomowerBaseEntity): +class AutomowerControlEntity(AutomowerBaseEntity): """Replies available when the mower is connected.""" @property def available(self) -> bool: """Return True if the device is available.""" - return super().available and self.mower_attributes.metadata.connected - - -class AutomowerControlEntity(AutomowerAvailableEntity): - """Replies available when the mower is connected and not in error state.""" - - @property - def available(self) -> bool: - """Return True if the device is available.""" - return super().available and _check_error_free(self.mower_attributes) + return ( + super().available + and self.mower_attributes.metadata.connected + and self.mower_attributes.mower.state != MowerStates.OFF + ) -class WorkAreaAvailableEntity(AutomowerAvailableEntity): +class WorkAreaAvailableEntity(AutomowerControlEntity): """Base entity for work areas.""" def __init__( diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py index daeb4a113b598..df312ae4ffde7 100644 --- a/homeassistant/components/husqvarna_automower/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower/lawn_mower.py @@ -20,7 +20,7 @@ from . import AutomowerConfigEntry from .const import DOMAIN, ERROR_STATES from .coordinator import AutomowerDataUpdateCoordinator -from .entity import AutomowerAvailableEntity, handle_sending_exception +from .entity import AutomowerBaseEntity, handle_sending_exception _LOGGER = logging.getLogger(__name__) @@ -89,7 +89,7 @@ def _async_add_new_devices(mower_ids: set[str]) -> None: ) -class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity): +class AutomowerLawnMowerEntity(AutomowerBaseEntity, LawnMowerEntity): """Defining each mower Entity.""" _attr_name = None diff --git a/homeassistant/components/huum/binary_sensor.py b/homeassistant/components/huum/binary_sensor.py index a8e094dda94f5..7bc03e9fe9476 100644 --- a/homeassistant/components/huum/binary_sensor.py +++ b/homeassistant/components/huum/binary_sensor.py @@ -27,7 +27,6 @@ async def async_setup_entry( class HuumDoorSensor(HuumBaseEntity, BinarySensorEntity): """Representation of a BinarySensor.""" - _attr_name = "Door" _attr_device_class = BinarySensorDeviceClass.DOOR def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: diff --git a/homeassistant/components/huum/light.py b/homeassistant/components/huum/light.py index 8eb35afdda2ea..9d3ec54101df1 100644 --- a/homeassistant/components/huum/light.py +++ b/homeassistant/components/huum/light.py @@ -32,7 +32,7 @@ async def async_setup_entry( class HuumLight(HuumBaseEntity, LightEntity): """Representation of a light.""" - _attr_name = "Light" + _attr_translation_key = "light" _attr_supported_color_modes = {ColorMode.ONOFF} _attr_color_mode = ColorMode.ONOFF diff --git a/homeassistant/components/huum/strings.json b/homeassistant/components/huum/strings.json index 68ab1adde6f72..55ccf0fdd81fe 100644 --- a/homeassistant/components/huum/strings.json +++ b/homeassistant/components/huum/strings.json @@ -18,5 +18,12 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "light": { + "light": { + "name": "[%key:component::light::title%]" + } + } } } diff --git a/homeassistant/components/imeon_inverter/coordinator.py b/homeassistant/components/imeon_inverter/coordinator.py index 8342240b9ff4a..f1963a45579d1 100644 --- a/homeassistant/components/imeon_inverter/coordinator.py +++ b/homeassistant/components/imeon_inverter/coordinator.py @@ -88,10 +88,7 @@ async def _async_update_data(self) -> dict[str, str | float | int]: # Store data for key, val in self._api.storage.items(): - if key == "timeline": - data[key] = val - else: - for sub_key, sub_val in val.items(): - data[f"{key}_{sub_key}"] = sub_val + for sub_key, sub_val in val.items(): + data[f"{key}_{sub_key}"] = sub_val return data diff --git a/homeassistant/components/imeon_inverter/icons.json b/homeassistant/components/imeon_inverter/icons.json index 1c74cf4c74576..6ede2416afae8 100644 --- a/homeassistant/components/imeon_inverter/icons.json +++ b/homeassistant/components/imeon_inverter/icons.json @@ -1,12 +1,6 @@ { "entity": { "sensor": { - "battery_autonomy": { - "default": "mdi:battery-clock" - }, - "battery_charge_time": { - "default": "mdi:battery-charging" - }, "battery_power": { "default": "mdi:battery" }, @@ -58,9 +52,6 @@ "meter_power": { "default": "mdi:power-plug" }, - "meter_power_protocol": { - "default": "mdi:protocol" - }, "output_current_l1": { "default": "mdi:current-ac" }, @@ -115,30 +106,12 @@ "temp_component_temperature": { "default": "mdi:thermometer" }, - "monitoring_building_consumption": { - "default": "mdi:home-lightning-bolt" - }, - "monitoring_economy_factor": { - "default": "mdi:chart-bar" - }, - "monitoring_grid_consumption": { - "default": "mdi:transmission-tower" - }, - "monitoring_grid_injection": { - "default": "mdi:transmission-tower-export" - }, - "monitoring_grid_power_flow": { - "default": "mdi:power-plug" - }, "monitoring_self_consumption": { "default": "mdi:percent" }, "monitoring_self_sufficiency": { "default": "mdi:percent" }, - "monitoring_solar_production": { - "default": "mdi:solar-power" - }, "monitoring_minute_building_consumption": { "default": "mdi:home-lightning-bolt" }, diff --git a/homeassistant/components/imeon_inverter/manifest.json b/homeassistant/components/imeon_inverter/manifest.json index 1398521dc45a1..a9a37f3fd9c7c 100644 --- a/homeassistant/components/imeon_inverter/manifest.json +++ b/homeassistant/components/imeon_inverter/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["imeon_inverter_api==0.3.12"], + "requirements": ["imeon_inverter_api==0.3.14"], "ssdp": [ { "manufacturer": "IMEON", diff --git a/homeassistant/components/imeon_inverter/sensor.py b/homeassistant/components/imeon_inverter/sensor.py index e1d05d0ecf6e5..32d40923fa14b 100644 --- a/homeassistant/components/imeon_inverter/sensor.py +++ b/homeassistant/components/imeon_inverter/sensor.py @@ -18,7 +18,6 @@ UnitOfFrequency, UnitOfPower, UnitOfTemperature, - UnitOfTime, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -34,20 +33,6 @@ SENSOR_DESCRIPTIONS = ( # Battery - SensorEntityDescription( - key="battery_autonomy", - translation_key="battery_autonomy", - native_unit_of_measurement=UnitOfTime.HOURS, - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.MEASUREMENT, - ), - SensorEntityDescription( - key="battery_charge_time", - translation_key="battery_charge_time", - native_unit_of_measurement=UnitOfTime.HOURS, - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.TOTAL, - ), SensorEntityDescription( key="battery_power", translation_key="battery_power", @@ -171,13 +156,6 @@ device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( - key="meter_power_protocol", - translation_key="meter_power_protocol", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), # AC Output SensorEntityDescription( key="output_current_l1", @@ -308,45 +286,6 @@ state_class=SensorStateClass.MEASUREMENT, ), # Monitoring (data over the last 24 hours) - SensorEntityDescription( - key="monitoring_building_consumption", - translation_key="monitoring_building_consumption", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), - SensorEntityDescription( - key="monitoring_economy_factor", - translation_key="monitoring_economy_factor", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), - SensorEntityDescription( - key="monitoring_grid_consumption", - translation_key="monitoring_grid_consumption", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), - SensorEntityDescription( - key="monitoring_grid_injection", - translation_key="monitoring_grid_injection", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), - SensorEntityDescription( - key="monitoring_grid_power_flow", - translation_key="monitoring_grid_power_flow", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), SensorEntityDescription( key="monitoring_self_consumption", translation_key="monitoring_self_consumption", @@ -361,14 +300,6 @@ state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, ), - SensorEntityDescription( - key="monitoring_solar_production", - translation_key="monitoring_solar_production", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), # Monitoring (instant minute data) SensorEntityDescription( key="monitoring_minute_building_consumption", diff --git a/homeassistant/components/imeon_inverter/strings.json b/homeassistant/components/imeon_inverter/strings.json index 218e1c4e4aa6f..86855361b8fa0 100644 --- a/homeassistant/components/imeon_inverter/strings.json +++ b/homeassistant/components/imeon_inverter/strings.json @@ -29,12 +29,6 @@ }, "entity": { "sensor": { - "battery_autonomy": { - "name": "Battery autonomy" - }, - "battery_charge_time": { - "name": "Battery charge time" - }, "battery_power": { "name": "Battery power" }, @@ -86,9 +80,6 @@ "meter_power": { "name": "Meter power" }, - "meter_power_protocol": { - "name": "Meter power protocol" - }, "output_current_l1": { "name": "Output current L1" }, @@ -143,30 +134,12 @@ "temp_component_temperature": { "name": "Component temperature" }, - "monitoring_building_consumption": { - "name": "Monitoring building consumption" - }, - "monitoring_economy_factor": { - "name": "Monitoring economy factor" - }, - "monitoring_grid_consumption": { - "name": "Monitoring grid consumption" - }, - "monitoring_grid_injection": { - "name": "Monitoring grid injection" - }, - "monitoring_grid_power_flow": { - "name": "Monitoring grid power flow" - }, "monitoring_self_consumption": { "name": "Monitoring self-consumption" }, "monitoring_self_sufficiency": { "name": "Monitoring self-sufficiency" }, - "monitoring_solar_production": { - "name": "Monitoring solar production" - }, "monitoring_minute_building_consumption": { "name": "Monitoring building consumption (minute)" }, diff --git a/homeassistant/components/keba/strings.json b/homeassistant/components/keba/strings.json index 49ce01f4332e7..1616df6237ba8 100644 --- a/homeassistant/components/keba/strings.json +++ b/homeassistant/components/keba/strings.json @@ -28,7 +28,7 @@ "fields": { "current": { "name": "Current", - "description": "The maximum current used for the charging process. The value is depending on the DIP-switch settings and the used cable of the charging station." + "description": "The maximum current used for the charging process. The value depends on the DIP switch settings and the cable used by the charging station." } } }, diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 65e36a4523e22..402816466ea42 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -74,7 +74,7 @@ }, "binary_sensor": { "eco_friendly_mode": { - "name": "Eco friendly" + "name": "Eco-friendly" }, "power_save_enabled": { "name": "Power saving mode" @@ -149,7 +149,7 @@ "cliff_error": "Fall prevention sensor has an error", "clutch_error": "Clutch error", "compressor_error": "Compressor error", - "dispensing_error": "Dispensor error", + "dispensing_error": "Dispenser error", "door_close_error": "Door closed error", "door_lock_error": "Door lock error", "door_open_error": "Door open", @@ -233,7 +233,7 @@ "styling_is_complete": "Styling is completed", "time_to_change_filter": "It is time to replace the filter", "time_to_change_water_filter": "You need to replace water filter", - "time_to_clean": "Need to selfcleaning", + "time_to_clean": "Need for self-cleaning", "time_to_clean_filter": "It is time to clean the filter", "timer_is_complete": "Timer has been completed", "washing_is_complete": "Washing is completed", diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 3d30fcd369e4f..7a1b51ac8aeb7 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -10,6 +10,9 @@ import voluptuous as vol from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, ATTR_EFFECT, ATTR_TRANSITION, LIGHT_TURN_ON_SCHEMA, @@ -234,6 +237,20 @@ async def set_state(self, **kwargs: Any) -> None: else: fade = 0 + if ATTR_BRIGHTNESS_STEP in kwargs or ATTR_BRIGHTNESS_STEP_PCT in kwargs: + brightness = self.brightness if self.is_on and self.brightness else 0 + + if ATTR_BRIGHTNESS_STEP in kwargs: + brightness += kwargs.pop(ATTR_BRIGHTNESS_STEP) + + else: + brightness_pct = round(brightness / 255 * 100) + brightness = round( + (brightness_pct + kwargs.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255 + ) + + kwargs[ATTR_BRIGHTNESS] = max(0, min(255, brightness)) + # These are both False if ATTR_POWER is not set power_on = kwargs.get(ATTR_POWER, False) power_off = not kwargs.get(ATTR_POWER, True) diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 20d7eb69ba4b2..7f603c9d1885a 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -310,7 +310,7 @@ "name": "Flow" }, "hepa_filter_condition": { - "name": "Hepa filter condition" + "name": "HEPA filter condition" }, "operational_state": { "name": "Operational state", diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 18893c238fe64..2ae412ed95e4f 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -462,8 +462,8 @@ "chicken_tikka_masala_with_rice": "Chicken Tikka Masala with rice", "chicken_whole": "Chicken", "chinese_cabbage_cut": "Chinese cabbage (cut)", - "chocolate_hazlenut_cake_one_large": "Chocolate hazlenut cake (one large)", - "chocolate_hazlenut_cake_several_small": "Chocolate hazlenut cake (several small)", + "chocolate_hazlenut_cake_one_large": "Chocolate hazelnut cake (one large)", + "chocolate_hazlenut_cake_several_small": "Chocolate hazelnut cake (several small)", "chongming_rapid_steam_cooking": "Chongming (rapid steam cooking)", "chongming_steam_cooking": "Chongming (steam cooking)", "choux_buns": "Choux buns", diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 8cb662703315c..ba869a7334bd4 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -422,7 +422,7 @@ "tilt_opened_value": "The value that will be sent to the \"tilt command topic\" when the cover tilt is opened.", "tilt_status_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the payload for the tilt status topic. Within the template the following variables are available: `entity_id`, `position_open`, `position_closed`, `tilt_min` and `tilt_max`. [Learn more.]({url}#tilt_status_template)", "tilt_status_topic": "The MQTT topic subscribed to receive tilt status update values. [Learn more.]({url}#tilt_status_topic)", - "tilt_optimistic": "Flag that defines if tilt works in optimistic mode. If tilt status topic is not defined, tilt works in optimisic mode by default. [Learn more.]({url}#tilt_optimistic)" + "tilt_optimistic": "Flag that defines if tilt works in optimistic mode. If tilt status topic is not defined, tilt works in optimistic mode by default. [Learn more.]({url}#tilt_optimistic)" } }, "light_brightness_settings": { diff --git a/homeassistant/components/ollama/entity.py b/homeassistant/components/ollama/entity.py index b2f0ebbb7b802..2581698e185b4 100644 --- a/homeassistant/components/ollama/entity.py +++ b/homeassistant/components/ollama/entity.py @@ -170,11 +170,13 @@ async def _transform_stream( class OllamaBaseLLMEntity(Entity): """Ollama base LLM entity.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, entry: OllamaConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the entity.""" self.entry = entry self.subentry = subentry - self._attr_name = subentry.title self._attr_unique_id = subentry.subentry_id model, _, version = subentry.data[CONF_MODEL].partition(":") diff --git a/homeassistant/components/onkyo/quality_scale.yaml b/homeassistant/components/onkyo/quality_scale.yaml index 1e8bf07e66ae5..758055a974c07 100644 --- a/homeassistant/components/onkyo/quality_scale.yaml +++ b/homeassistant/components/onkyo/quality_scale.yaml @@ -8,10 +8,7 @@ rules: brands: done common-modules: done config-flow: done - config-flow-test-coverage: - status: todo - comment: | - Coverage is 100%, but the tests need to be improved. + config-flow-test-coverage: done dependency-transparency: done docs-actions: done docs-high-level-description: done @@ -39,9 +36,9 @@ rules: parallel-updates: todo reauthentication-flow: status: exempt - comment: | - This integration does not require authentication. - test-coverage: todo + comment: This integration does not require authentication. + test-coverage: done + # Gold devices: todo diagnostics: todo diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 93713c78d9c4b..c1b2f970f07f8 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -274,11 +274,13 @@ async def _transform_stream( class OpenAIBaseLLMEntity(Entity): """OpenAI conversation agent.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the entity.""" self.entry = entry self.subentry = subentry - self._attr_name = subentry.title self._attr_unique_id = subentry.subentry_id self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, subentry.subentry_id)}, diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index ffbcbe7a97038..1a99f47c38c6d 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -164,7 +164,7 @@ def _get_core_climate_unique_id( CORE_CLIMATE_TIMER: SleepIQNumberEntityDescription( key=CORE_CLIMATE_TIMER, native_min_value=0, - native_max_value=600, + native_max_value=SleepIQCoreClimate.max_core_climate_time, native_step=30, name=ENTITY_TYPES[CORE_CLIMATE_TIMER], icon="mdi:timer", diff --git a/homeassistant/components/smhi/coordinator.py b/homeassistant/components/smhi/coordinator.py index ba7542694df5e..d8e85917db515 100644 --- a/homeassistant/components/smhi/coordinator.py +++ b/homeassistant/components/smhi/coordinator.py @@ -24,6 +24,7 @@ class SMHIForecastData: daily: list[SMHIForecast] hourly: list[SMHIForecast] + twice_daily: list[SMHIForecast] class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]): @@ -52,6 +53,9 @@ async def _async_update_data(self) -> SMHIForecastData: async with asyncio.timeout(TIMEOUT): _forecast_daily = await self._smhi_api.async_get_daily_forecast() _forecast_hourly = await self._smhi_api.async_get_hourly_forecast() + _forecast_twice_daily = ( + await self._smhi_api.async_get_twice_daily_forecast() + ) except SmhiForecastException as ex: raise UpdateFailed( "Failed to retrieve the forecast from the SMHI API" @@ -60,6 +64,7 @@ async def _async_update_data(self) -> SMHIForecastData: return SMHIForecastData( daily=_forecast_daily, hourly=_forecast_hourly, + twice_daily=_forecast_twice_daily, ) @property diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index ccfff7cc2e5fe..9496321b8b428 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -26,6 +26,7 @@ ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_CONDITION, ATTR_FORECAST_HUMIDITY, + ATTR_FORECAST_IS_DAYTIME, ATTR_FORECAST_NATIVE_PRECIPITATION, ATTR_FORECAST_NATIVE_PRESSURE, ATTR_FORECAST_NATIVE_TEMP, @@ -109,7 +110,9 @@ class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity): _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND _attr_native_pressure_unit = UnitOfPressure.HPA _attr_supported_features = ( - WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + WeatherEntityFeature.FORECAST_DAILY + | WeatherEntityFeature.FORECAST_HOURLY + | WeatherEntityFeature.FORECAST_TWICE_DAILY ) _attr_name = None @@ -146,7 +149,7 @@ def _handle_coordinator_update(self) -> None: super()._handle_coordinator_update() def _get_forecast_data( - self, forecast_data: list[SMHIForecast] | None + self, forecast_data: list[SMHIForecast] | None, forecast_type: str ) -> list[Forecast] | None: """Get forecast data.""" if forecast_data is None or len(forecast_data) < 3: @@ -161,7 +164,7 @@ def _get_forecast_data( ): condition = ATTR_CONDITION_CLEAR_NIGHT - data.append( + new_forecast = Forecast( { ATTR_FORECAST_TIME: forecast["valid_time"].isoformat(), ATTR_FORECAST_NATIVE_TEMP: forecast["temperature_max"], @@ -179,13 +182,23 @@ def _get_forecast_data( ATTR_FORECAST_CLOUD_COVERAGE: forecast["total_cloud"], } ) + if forecast_type == "twice_daily": + new_forecast[ATTR_FORECAST_IS_DAYTIME] = False + if forecast["valid_time"].hour == 12: + new_forecast[ATTR_FORECAST_IS_DAYTIME] = True + + data.append(new_forecast) return data def _async_forecast_daily(self) -> list[Forecast] | None: """Service to retrieve the daily forecast.""" - return self._get_forecast_data(self.coordinator.data.daily) + return self._get_forecast_data(self.coordinator.data.daily, "daily") def _async_forecast_hourly(self) -> list[Forecast] | None: """Service to retrieve the hourly forecast.""" - return self._get_forecast_data(self.coordinator.data.hourly) + return self._get_forecast_data(self.coordinator.data.hourly, "hourly") + + def _async_forecast_twice_daily(self) -> list[Forecast] | None: + """Service to retrieve the twice daily forecast.""" + return self._get_forecast_data(self.coordinator.data.twice_daily, "twice_daily") diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index e8b8efbda0a57..567e9e3a110b9 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -182,7 +182,7 @@ def __init__( TemplateEntity.__init__(self, hass, config, unique_id) self._attr_device_class = config.get(CONF_DEVICE_CLASS) - self._template = config[CONF_STATE] + self._template: template.Template = config[CONF_STATE] self._delay_cancel = None self._delay_on = None self._delay_on_raw = config.get(CONF_DELAY_ON) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 0bbc6b77f5777..8f88baea09126 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -24,7 +24,6 @@ CONF_ENTITY_ID, CONF_FRIENDLY_NAME, CONF_NAME, - CONF_OPTIMISTIC, CONF_STATE, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, @@ -41,6 +40,7 @@ from .helpers import async_setup_template_platform from .template_entity import ( TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, ) @@ -97,7 +97,6 @@ vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, vol.Inclusive(OPEN_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_OPTIMISTIC): cv.boolean, vol.Optional(CONF_POSITION): cv.template, vol.Optional(CONF_STATE): cv.template, vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, @@ -106,7 +105,9 @@ vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, } - ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema), + ) + .extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) + .extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA), cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), ) @@ -121,7 +122,6 @@ vol.Optional(CONF_POSITION_TEMPLATE): cv.template, vol.Optional(CONF_TILT_TEMPLATE): cv.template, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_OPTIMISTIC): cv.boolean, vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, @@ -129,7 +129,9 @@ vol.Optional(CONF_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_UNIQUE_ID): cv.string, } - ).extend(TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY.schema), + ) + .extend(TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY.schema) + .extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA), cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), ) @@ -162,21 +164,17 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): """Representation of a template cover features.""" _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" - self._template = config.get(CONF_STATE) self._position_template = config.get(CONF_POSITION) self._tilt_template = config.get(CONF_TILT) self._attr_device_class = config.get(CONF_DEVICE_CLASS) - optimistic = config.get(CONF_OPTIMISTIC) - self._optimistic = optimistic or ( - optimistic is None and not self._template and not self._position_template - ) tilt_optimistic = config.get(CONF_TILT_OPTIMISTIC) self._tilt_optimistic = tilt_optimistic or not self._tilt_template self._position: int | None = None @@ -318,7 +316,7 @@ async def async_open_cover(self, **kwargs: Any) -> None: run_variables={"position": 100}, context=self._context, ) - if self._optimistic: + if self._attr_assumed_state: self._position = 100 self.async_write_ha_state() @@ -332,7 +330,7 @@ async def async_close_cover(self, **kwargs: Any) -> None: run_variables={"position": 0}, context=self._context, ) - if self._optimistic: + if self._attr_assumed_state: self._position = 0 self.async_write_ha_state() @@ -349,7 +347,7 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: run_variables={"position": self._position}, context=self._context, ) - if self._optimistic: + if self._attr_assumed_state: self.async_write_ha_state() async def async_open_cover_tilt(self, **kwargs: Any) -> None: @@ -493,10 +491,10 @@ def _handle_coordinator_update(self) -> None: updater(rendered) write_ha_state = True - if not self._optimistic: + if not self._attr_assumed_state: self.async_set_context(self.coordinator.data["context"]) write_ha_state = True - elif self._optimistic and len(self._rendered) > 0: + elif self._attr_assumed_state and len(self._rendered) > 0: # In case any non optimistic template write_ha_state = True diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index 31c48917a1fa1..e9a630594d71a 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -4,12 +4,12 @@ from collections.abc import Sequence from typing import Any -from homeassistant.const import CONF_DEVICE_ID +from homeassistant.const import CONF_DEVICE_ID, CONF_OPTIMISTIC, CONF_STATE from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.script import Script, _VarsType -from homeassistant.helpers.template import TemplateStateFromEntityId +from homeassistant.helpers.template import Template, TemplateStateFromEntityId from homeassistant.helpers.typing import ConfigType from .const import CONF_OBJECT_ID @@ -19,13 +19,26 @@ class AbstractTemplateEntity(Entity): """Actions linked to a template entity.""" _entity_id_format: str + _optimistic_entity: bool = False + _template: Template | None = None - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + ) -> None: """Initialize the entity.""" self.hass = hass self._action_scripts: dict[str, Script] = {} + if self._optimistic_entity: + self._template = config.get(CONF_STATE) + + self._attr_assumed_state = self._template is None or config.get( + CONF_OPTIMISTIC, False + ) + if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( self._entity_id_format, object_id, hass=self.hass diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 0ad99cd6ae880..8e298c285394e 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -15,7 +15,7 @@ SelectEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_STATE +from homeassistant.const import CONF_NAME, CONF_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( @@ -34,6 +34,7 @@ ) from .template_entity import ( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, ) @@ -45,7 +46,6 @@ CONF_SELECT_OPTION = "select_option" DEFAULT_NAME = "Template Select" -DEFAULT_OPTIMISTIC = False SELECT_COMMON_SCHEMA = vol.Schema( { @@ -55,15 +55,9 @@ } ) -SELECT_YAML_SCHEMA = ( - vol.Schema( - { - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - } - ) - .extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) - .extend(SELECT_COMMON_SCHEMA.schema) -) +SELECT_YAML_SCHEMA = SELECT_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) SELECT_CONFIG_ENTRY_SCHEMA = SELECT_COMMON_SCHEMA.extend( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema @@ -117,24 +111,20 @@ class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity): """Representation of a template select features.""" _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" - self._template = config.get(CONF_STATE) - self._options_template = config[ATTR_OPTIONS] - self._attr_assumed_state = self._optimistic = ( - self._template is None or config.get(CONF_OPTIMISTIC, DEFAULT_OPTIMISTIC) - ) self._attr_options = [] self._attr_current_option = None async def async_select_option(self, option: str) -> None: """Change the selected option.""" - if self._optimistic: + if self._attr_assumed_state: self._attr_current_option = option self.async_write_ha_state() if select_option := self._action_scripts.get(CONF_SELECT_OPTION): diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index ae4738545029f..1bc49bceafd2a 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -17,6 +17,7 @@ CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, + CONF_OPTIMISTIC, CONF_PATH, CONF_VARIABLES, STATE_UNKNOWN, @@ -100,6 +101,11 @@ ).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) +TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA = { + vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, +} + + def make_template_entity_common_modern_schema( default_name: str, ) -> vol.Schema: diff --git a/homeassistant/components/tesla_fleet/const.py b/homeassistant/components/tesla_fleet/const.py index d73234b1fdd4c..761bbebf7a831 100644 --- a/homeassistant/components/tesla_fleet/const.py +++ b/homeassistant/components/tesla_fleet/const.py @@ -14,9 +14,8 @@ LOGGER = logging.getLogger(__package__) -CLIENT_ID = "71b813eb-4a2e-483a-b831-4dec5cb9bf0d" -AUTHORIZE_URL = "https://auth.tesla.com/oauth2/v3/authorize" -TOKEN_URL = "https://auth.tesla.com/oauth2/v3/token" +AUTHORIZE_URL = "https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/authorize" +TOKEN_URL = "https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token" SCOPES = [ Scope.OPENID, diff --git a/homeassistant/components/tts/entity.py b/homeassistant/components/tts/entity.py index dc6f22570fc79..aea5be6d0da95 100644 --- a/homeassistant/components/tts/entity.py +++ b/homeassistant/components/tts/entity.py @@ -165,18 +165,6 @@ async def internal_async_stream_tts_audio( self.async_write_ha_state() return await self.async_stream_tts_audio(request) - @final - async def async_internal_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] - ) -> TtsAudioType: - """Load tts audio file from the engine and update state. - - Return a tuple of file extension and data as bytes. - """ - self.__last_tts_loaded = dt_util.utcnow().isoformat() - self.async_write_ha_state() - return await self.async_get_tts_audio(message, language, options=options) - async def async_stream_tts_audio( self, request: TTSAudioRequest ) -> TTSAudioResponse: diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 106075e9314b5..6c3aa14615800 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -153,6 +153,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool # Register known device IDs device_registry = dr.async_get(hass) for device in manager.device_map.values(): + if not device.status and not device.status_range and not device.function: + # If the device has no status, status_range or function, + # it cannot be supported + LOGGER.info( + "Device %s (%s) has been ignored as it does not provide any" + " standard instructions (status, status_range and function are" + " all empty) - see %s", + device.product_name, + device.id, + "https://github.com/tuya/tuya-device-sharing-sdk/issues/11", + ) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, device.id)}, diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index 6539d98e9d8f1..06fdc1545c5b8 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -21,6 +21,7 @@ from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity from .models import IntegerTypeData +from .util import ActionDPCodeNotFoundError @dataclass(frozen=True) @@ -169,17 +170,28 @@ def current_humidity(self) -> int | None: def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" + if self._switch_dpcode is None: + raise ActionDPCodeNotFoundError( + self.device, + self.entity_description.dpcode or self.entity_description.key, + ) self._send_command([{"code": self._switch_dpcode, "value": True}]) def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" + if self._switch_dpcode is None: + raise ActionDPCodeNotFoundError( + self.device, + self.entity_description.dpcode or self.entity_description.key, + ) self._send_command([{"code": self._switch_dpcode, "value": False}]) def set_humidity(self, humidity: int) -> None: """Set new target humidity.""" if self._set_humidity is None: - raise RuntimeError( - "Cannot set humidity, device doesn't provide methods to set it" + raise ActionDPCodeNotFoundError( + self.device, + self.entity_description.humidity, ) self._send_command( diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 383ece6eaee70..e7988adfafbda 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -26,6 +26,7 @@ ) from .entity import TuyaEntity from .models import IntegerTypeData +from .util import ActionDPCodeNotFoundError # All descriptions can be found here. Mostly the Integer data types in the # default instructions set of each category end up being a number. @@ -473,7 +474,7 @@ def native_value(self) -> float | None: def set_native_value(self, value: float) -> None: """Set new value.""" if self._number is None: - raise RuntimeError("Cannot set value, device doesn't provide type data") + raise ActionDPCodeNotFoundError(self.device, self.entity_description.key) self._send_command( [ diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index abcafc490f99a..954f5dbda8a40 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -916,5 +916,10 @@ "name": "Siren" } } + }, + "exceptions": { + "action_dpcode_not_found": { + "message": "Unable to process action as the device does not provide a corresponding function code (expected one of {expected} in {available})." + } } } diff --git a/homeassistant/components/tuya/util.py b/homeassistant/components/tuya/util.py index c1615b89c2d67..916a7cfddf4f8 100644 --- a/homeassistant/components/tuya/util.py +++ b/homeassistant/components/tuya/util.py @@ -2,6 +2,12 @@ from __future__ import annotations +from tuya_sharing import CustomerDevice + +from homeassistant.exceptions import ServiceValidationError + +from .const import DOMAIN, DPCode + def remap_value( value: float, @@ -15,3 +21,25 @@ def remap_value( if reverse: value = from_max - value + from_min return ((value - from_min) / (from_max - from_min)) * (to_max - to_min) + to_min + + +class ActionDPCodeNotFoundError(ServiceValidationError): + """Custom exception for action DP code not found errors.""" + + def __init__( + self, device: CustomerDevice, expected: str | DPCode | tuple[DPCode, ...] | None + ) -> None: + """Initialize the error with device and expected DP codes.""" + if expected is None: + expected = () # empty tuple for no expected codes + elif isinstance(expected, str): + expected = (DPCode(expected),) + + super().__init__( + translation_domain=DOMAIN, + translation_key="action_dpcode_not_found", + translation_placeholders={ + "expected": str(sorted([dp.value for dp in expected])), + "available": str(sorted(device.function.keys())), + }, + ) diff --git a/homeassistant/components/wmspro/button.py b/homeassistant/components/wmspro/button.py index f1ab0489b8628..1b2772a9c80ad 100644 --- a/homeassistant/components/wmspro/button.py +++ b/homeassistant/components/wmspro/button.py @@ -23,7 +23,7 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [ WebControlProIdentifyButton(config_entry.entry_id, dest) for dest in hub.dests.values() - if dest.action(WMS_WebControl_pro_API_actionDescription.Identify) + if dest.hasAction(WMS_WebControl_pro_API_actionDescription.Identify) ] async_add_entities(entities) diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py index b6f100280adfd..e7255d478cb29 100644 --- a/homeassistant/components/wmspro/cover.py +++ b/homeassistant/components/wmspro/cover.py @@ -32,9 +32,9 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [] for dest in hub.dests.values(): - if dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive): + if dest.hasAction(WMS_WebControl_pro_API_actionDescription.AwningDrive): entities.append(WebControlProAwning(config_entry.entry_id, dest)) - elif dest.action( + elif dest.hasAction( WMS_WebControl_pro_API_actionDescription.RollerShutterBlindDrive ): entities.append(WebControlProRollerShutter(config_entry.entry_id, dest)) diff --git a/homeassistant/components/wmspro/light.py b/homeassistant/components/wmspro/light.py index 52d092ed9f089..2326734ceafba 100644 --- a/homeassistant/components/wmspro/light.py +++ b/homeassistant/components/wmspro/light.py @@ -33,9 +33,9 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [] for dest in hub.dests.values(): - if dest.action(WMS_WebControl_pro_API_actionDescription.LightDimming): + if dest.hasAction(WMS_WebControl_pro_API_actionDescription.LightDimming): entities.append(WebControlProDimmer(config_entry.entry_id, dest)) - elif dest.action(WMS_WebControl_pro_API_actionDescription.LightSwitch): + elif dest.hasAction(WMS_WebControl_pro_API_actionDescription.LightSwitch): entities.append(WebControlProLight(config_entry.entry_id, dest)) async_add_entities(entities) diff --git a/homeassistant/components/wmspro/manifest.json b/homeassistant/components/wmspro/manifest.json index 9185768165a8a..9dbcf09a7d43b 100644 --- a/homeassistant/components/wmspro/manifest.json +++ b/homeassistant/components/wmspro/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/wmspro", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["pywmspro==0.3.0"] + "requirements": ["pywmspro==0.3.2"] } diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 74ffedbc53f54..25c342cf87d89 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -772,6 +772,35 @@ class ZWaveDiscoverySchema: }, ), ), + # ZWA-2, discover LED control as configuration, default disabled + ## Production firmware (1.0) -> Color Switch CC + ZWaveDiscoverySchema( + platform=Platform.LIGHT, + manufacturer_id={0x0466}, + product_id={0x0001}, + product_type={0x0001}, + hint="zwa2_led_color", + primary_value=COLOR_SWITCH_CURRENT_VALUE_SCHEMA, + absent_values=[ + SWITCH_BINARY_CURRENT_VALUE_SCHEMA, + SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + ], + entity_category=EntityCategory.CONFIG, + ), + ## Day-1 firmware update (1.1) -> Binary Switch CC + ZWaveDiscoverySchema( + platform=Platform.LIGHT, + manufacturer_id={0x0466}, + product_id={0x0001}, + product_type={0x0001}, + hint="zwa2_led_onoff", + primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA, + absent_values=[ + COLOR_SWITCH_CURRENT_VALUE_SCHEMA, + SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + ], + entity_category=EntityCategory.CONFIG, + ), # ====== START OF GENERIC MAPPING SCHEMAS ======= # locks # Door Lock CC diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 23ec240e5a75a..9b7c022241003 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -77,7 +77,11 @@ def async_add_light(info: ZwaveDiscoveryInfo) -> None: driver = client.driver assert driver is not None # Driver is ready before platforms are loaded. - if info.platform_hint == "color_onoff": + if info.platform_hint == "zwa2_led_color": + async_add_entities([ZWA2LEDColorLight(config_entry, driver, info)]) + elif info.platform_hint == "zwa2_led_onoff": + async_add_entities([ZWA2LEDOnOffLight(config_entry, driver, info)]) + elif info.platform_hint == "color_onoff": async_add_entities([ZwaveColorOnOffLight(config_entry, driver, info)]) else: async_add_entities([ZwaveLight(config_entry, driver, info)]) @@ -183,7 +187,10 @@ def __init__( if self._supports_color_temp: self._supported_color_modes.add(ColorMode.COLOR_TEMP) if not self._supported_color_modes: - self._supported_color_modes.add(ColorMode.BRIGHTNESS) + if self.info.primary_value.command_class == CommandClass.SWITCH_BINARY: + self._supported_color_modes.add(ColorMode.ONOFF) + else: + self._supported_color_modes.add(ColorMode.BRIGHTNESS) self._calculate_color_values() # Entity class attributes @@ -677,3 +684,29 @@ async def async_turn_off(self, **kwargs: Any) -> None: colors, kwargs.get(ATTR_TRANSITION), ) + + +class ZWA2LEDColorLight(ZwaveColorOnOffLight): + """LED entity specific to the ZWA-2 (legacy firmware).""" + + _attr_has_entity_name = True + + def __init__( + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize the ZWA-2 LED entity.""" + super().__init__(config_entry, driver, info) + self._attr_name = "LED" + + +class ZWA2LEDOnOffLight(ZwaveLight): + """LED entity specific to the ZWA-2.""" + + _attr_has_entity_name = True + + def __init__( + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize the ZWA-2 LED entity.""" + super().__init__(config_entry, driver, info) + self._attr_name = "LED" diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 4d68aa2bcbcc3..687d06cd70328 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -274,7 +274,7 @@ }, "step": { "init": { - "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.", + "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.\n\nNote: Battery powered sleeping devices need to be woken up during re-interview for it to work. How to wake up the device is device specific and is normally explained in the device manual.", "menu_options": { "confirm": "Re-interview device", "ignore": "Ignore device config update" diff --git a/requirements_all.txt b/requirements_all.txt index 479e5598aaf2f..884a54a9f922a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1234,7 +1234,7 @@ igloohome-api==0.1.1 ihcsdk==2.8.5 # homeassistant.components.imeon_inverter -imeon_inverter_api==0.3.12 +imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib imgw_pib==1.4.2 @@ -2603,7 +2603,7 @@ pywilight==0.0.74 pywizlight==0.6.3 # homeassistant.components.wmspro -pywmspro==0.3.0 +pywmspro==0.3.2 # homeassistant.components.ws66i pyws66i==1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eae65039b8f20..1f6cc235624b5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1068,7 +1068,7 @@ ifaddr==0.2.0 igloohome-api==0.1.1 # homeassistant.components.imeon_inverter -imeon_inverter_api==0.3.12 +imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib imgw_pib==1.4.2 @@ -2161,7 +2161,7 @@ pywilight==0.0.74 pywizlight==0.6.3 # homeassistant.components.wmspro -pywmspro==0.3.0 +pywmspro==0.3.2 # homeassistant.components.ws66i pyws66i==1.1 diff --git a/tests/components/blue_current/__init__.py b/tests/components/blue_current/__init__.py index 97acff39a6277..402d644747a2f 100644 --- a/tests/components/blue_current/__init__.py +++ b/tests/components/blue_current/__init__.py @@ -4,18 +4,28 @@ from asyncio import Event, Future from dataclasses import dataclass +from typing import Any from unittest.mock import MagicMock, patch from bluecurrent_api import Client +from homeassistant.components.blue_current import EVSE_ID, PLUG_AND_CHARGE +from homeassistant.components.blue_current.const import PUBLIC_CHARGING from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +DEFAULT_CHARGE_POINT_OPTIONS = { + PLUG_AND_CHARGE: {"value": False, "permission": "write"}, + PUBLIC_CHARGING: {"value": True, "permission": "write"}, +} + DEFAULT_CHARGE_POINT = { "evse_id": "101", "model_type": "", "name": "", + "activity": "available", + **DEFAULT_CHARGE_POINT_OPTIONS, } @@ -77,11 +87,20 @@ async def get_grid_status(evse_id: str) -> None: """Send the grid status to the callback.""" await client_mock.receiver({"object": "GRID_STATUS", "data": grid}) + async def update_charge_point( + evse_id: str, event_object: str, settings: dict[str, Any] + ) -> None: + """Update the charge point data by sending an event.""" + await client_mock.receiver( + {"object": event_object, "data": {EVSE_ID: evse_id, **settings}} + ) + client_mock.connect.side_effect = connect client_mock.wait_for_charge_points.side_effect = wait_for_charge_points client_mock.get_charge_points.side_effect = get_charge_points client_mock.get_status.side_effect = get_status client_mock.get_grid_status.side_effect = get_grid_status + client_mock.update_charge_point = update_charge_point return client_mock diff --git a/tests/components/blue_current/test_sensor.py b/tests/components/blue_current/test_sensor.py index cf20b7334b459..773ffbccd9781 100644 --- a/tests/components/blue_current/test_sensor.py +++ b/tests/components/blue_current/test_sensor.py @@ -7,17 +7,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import init_integration +from . import DEFAULT_CHARGE_POINT, init_integration from tests.common import MockConfigEntry -charge_point = { - "evse_id": "101", - "model_type": "", - "name": "", -} - - charge_point_status = { "actual_v1": 14, "actual_v2": 18, @@ -97,7 +90,7 @@ async def test_sensors_created( hass, config_entry, "sensor", - charge_point, + DEFAULT_CHARGE_POINT, charge_point_status | charge_point_status_timestamps, grid, ) @@ -116,7 +109,7 @@ async def test_sensors( ) -> None: """Test the underlying sensors.""" await init_integration( - hass, config_entry, "sensor", charge_point, charge_point_status, grid + hass, config_entry, "sensor", DEFAULT_CHARGE_POINT, charge_point_status, grid ) for entity_id, key in charge_point_entity_ids.items(): diff --git a/tests/components/blue_current/test_switch.py b/tests/components/blue_current/test_switch.py new file mode 100644 index 0000000000000..c7837816d75ed --- /dev/null +++ b/tests/components/blue_current/test_switch.py @@ -0,0 +1,152 @@ +"""The tests for Bluecurrent switches.""" + +from homeassistant.components.blue_current import CHARGEPOINT_SETTINGS, PLUG_AND_CHARGE +from homeassistant.components.blue_current.const import ( + ACTIVITY, + CHARGEPOINT_STATUS, + PUBLIC_CHARGING, + UNAVAILABLE, +) +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import DEFAULT_CHARGE_POINT, init_integration + +from tests.common import MockConfigEntry + + +async def test_switches( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test the underlying switches.""" + + await init_integration(hass, config_entry, Platform.SWITCH) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for switch in entity_entries: + state = hass.states.get(switch.entity_id) + + assert state and state.state == STATE_OFF + entry = entity_registry.async_get(switch.entity_id) + assert entry and entry.unique_id == switch.unique_id + + +async def test_switches_offline( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test if switches are disabled when needed.""" + charge_point = DEFAULT_CHARGE_POINT.copy() + charge_point[ACTIVITY] = "offline" + + await init_integration(hass, config_entry, Platform.SWITCH, charge_point) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for switch in entity_entries: + state = hass.states.get(switch.entity_id) + + assert state and state.state == UNAVAILABLE + entry = entity_registry.async_get(switch.entity_id) + assert entry and entry.entity_id == switch.entity_id + + +async def test_block_switch_availability( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test if the block switch is unavailable when charging.""" + charge_point = DEFAULT_CHARGE_POINT.copy() + charge_point[ACTIVITY] = "charging" + + await init_integration(hass, config_entry, Platform.SWITCH, charge_point) + + state = hass.states.get("switch.101_block_charge_point") + assert state and state.state == UNAVAILABLE + + +async def test_toggle( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test the on / off methods and if the switch gets updated.""" + await init_integration(hass, config_entry, Platform.SWITCH) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for switch in entity_entries: + state = hass.states.get(switch.entity_id) + + assert state and state.state == STATE_OFF + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": switch.entity_id}, + blocking=True, + ) + + state = hass.states.get(switch.entity_id) + assert state and state.state == STATE_ON + + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": switch.entity_id}, + blocking=True, + ) + + state = hass.states.get(switch.entity_id) + assert state and state.state == STATE_OFF + + +async def test_setting_change( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test if the state of the switches are updated when an update message from the websocket comes in.""" + integration = await init_integration(hass, config_entry, Platform.SWITCH) + client_mock = integration[0] + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for switch in entity_entries: + state = hass.states.get(switch.entity_id) + assert state.state == STATE_OFF + + await client_mock.update_charge_point( + "101", + CHARGEPOINT_SETTINGS, + { + PLUG_AND_CHARGE: True, + PUBLIC_CHARGING: {"value": False, "permission": "write"}, + }, + ) + + charge_cards_only_switch = hass.states.get("switch.101_linked_charging_cards_only") + assert charge_cards_only_switch.state == STATE_ON + + plug_and_charge_switch = hass.states.get("switch.101_plug_charge") + assert plug_and_charge_switch.state == STATE_ON + + plug_and_charge_switch = hass.states.get("switch.101_block_charge_point") + assert plug_and_charge_switch.state == STATE_OFF + + await client_mock.update_charge_point( + "101", CHARGEPOINT_STATUS, {ACTIVITY: UNAVAILABLE} + ) + + charge_cards_only_switch = hass.states.get("switch.101_linked_charging_cards_only") + assert charge_cards_only_switch.state == STATE_UNAVAILABLE + + plug_and_charge_switch = hass.states.get("switch.101_plug_charge") + assert plug_and_charge_switch.state == STATE_UNAVAILABLE + + switch = hass.states.get("switch.101_block_charge_point") + assert switch.state == STATE_ON diff --git a/tests/components/huum/snapshots/test_light.ambr b/tests/components/huum/snapshots/test_light.ambr index 918210272b288..da449c16fe8a9 100644 --- a/tests/components/huum/snapshots/test_light.ambr +++ b/tests/components/huum/snapshots/test_light.ambr @@ -33,7 +33,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'light', 'unique_id': 'AABBCC112233', 'unit_of_measurement': None, }) diff --git a/tests/components/imeon_inverter/snapshots/test_sensor.ambr b/tests/components/imeon_inverter/snapshots/test_sensor.ambr index 8816889f0499d..fb59aa9dedef6 100644 --- a/tests/components/imeon_inverter/snapshots/test_sensor.ambr +++ b/tests/components/imeon_inverter/snapshots/test_sensor.ambr @@ -55,118 +55,6 @@ 'state': '25.0', }) # --- -# name: test_sensors[sensor.imeon_inverter_battery_autonomy-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_battery_autonomy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Battery autonomy', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'battery_autonomy', - 'unique_id': '111111111111111_battery_autonomy', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_battery_autonomy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Imeon inverter Battery autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_battery_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4.5', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_battery_charge_time-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_battery_charge_time', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Battery charge time', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'battery_charge_time', - 'unique_id': '111111111111111_battery_charge_time', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_battery_charge_time-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Imeon inverter Battery charge time', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_battery_charge_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '120', - }) -# --- # name: test_sensors[sensor.imeon_inverter_battery_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1172,118 +1060,6 @@ 'state': '2000.0', }) # --- -# name: test_sensors[sensor.imeon_inverter_meter_power_protocol-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_meter_power_protocol', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Meter power protocol', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'meter_power_protocol', - 'unique_id': '111111111111111_meter_power_protocol', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_meter_power_protocol-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Imeon inverter Meter power protocol', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_meter_power_protocol', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2018.0', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_building_consumption', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring building consumption', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_building_consumption', - 'unique_id': '111111111111111_monitoring_building_consumption', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Imeon inverter Monitoring building consumption', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_building_consumption', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3000.0', - }) -# --- # name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption_minute-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1340,117 +1116,6 @@ 'state': '50.0', }) # --- -# name: test_sensors[sensor.imeon_inverter_monitoring_economy_factor-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_economy_factor', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Monitoring economy factor', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_economy_factor', - 'unique_id': '111111111111111_monitoring_economy_factor', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_economy_factor-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Imeon inverter Monitoring economy factor', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_economy_factor', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.8', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_consumption', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring grid consumption', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_grid_consumption', - 'unique_id': '111111111111111_monitoring_grid_consumption', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Imeon inverter Monitoring grid consumption', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_consumption', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '500.0', - }) -# --- # name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption_minute-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1507,62 +1172,6 @@ 'state': '8.3', }) # --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_injection', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring grid injection', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_grid_injection', - 'unique_id': '111111111111111_monitoring_grid_injection', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Imeon inverter Monitoring grid injection', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_injection', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '700.0', - }) -# --- # name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection_minute-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1619,62 +1228,6 @@ 'state': '11.7', }) # --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_power_flow', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring grid power flow', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_grid_power_flow', - 'unique_id': '111111111111111_monitoring_grid_power_flow', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Imeon inverter Monitoring grid power flow', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_power_flow', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '-200.0', - }) -# --- # name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow_minute-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1841,62 +1394,6 @@ 'state': '90.0', }) # --- -# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_solar_production', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring solar production', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_solar_production', - 'unique_id': '111111111111111_monitoring_solar_production', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Imeon inverter Monitoring solar production', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_solar_production', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2600.0', - }) -# --- # name: test_sensors[sensor.imeon_inverter_monitoring_solar_production_minute-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index d66908c1b1aeb..edb13c259e822 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -30,6 +30,8 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, ATTR_COLOR_MODE, ATTR_COLOR_NAME, ATTR_COLOR_TEMP_KELVIN, @@ -1735,6 +1737,48 @@ async def test_transitions_color_bulb(hass: HomeAssistant) -> None: bulb.set_color.reset_mock() +async def test_lifx_set_state_brightness(hass: HomeAssistant) -> None: + """Test lifx.set_state works with brightness, brightness_pct and brightness_step.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb_new_firmware() + bulb.power_level = 65535 + bulb.color = [0, 0, 32768, 3500] + with ( + _patch_discovery(device=bulb), + _patch_config_flow_try_connect(device=bulb), + _patch_device(device=bulb), + ): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + # brightness_step should convert from 8 bit to 16 bit + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS_STEP: 128}, + blocking=True, + ) + + assert bulb.set_color.calls[0][0][0] == [0, 0, 65535, 3500] + bulb.set_color.reset_mock() + + # brightness_step_pct should convert from percentage to 16 bit + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS_STEP_PCT: 50}, + blocking=True, + ) + + assert bulb.set_color.calls[0][0][0] == [0, 0, 65535, 3500] + bulb.set_color.reset_mock() + + async def test_lifx_set_state_color(hass: HomeAssistant) -> None: """Test lifx.set_state works with color names and RGB.""" config_entry = MockConfigEntry( diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 140384283cc2c..bff4ad7909dea 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -250,7 +250,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Hepa filter condition', + 'original_name': 'HEPA filter condition', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, @@ -263,7 +263,7 @@ # name: test_sensors[air_purifier][sensor.air_purifier_hepa_filter_condition-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Air Purifier Hepa filter condition', + 'friendly_name': 'Air Purifier HEPA filter condition', 'state_class': , 'unit_of_measurement': '%', }), @@ -2985,7 +2985,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Hepa filter condition', + 'original_name': 'HEPA filter condition', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, @@ -2998,7 +2998,7 @@ # name: test_sensors[extractor_hood][sensor.mock_extractor_hood_hepa_filter_condition-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Extractor hood Hepa filter condition', + 'friendly_name': 'Mock Extractor hood HEPA filter condition', 'state_class': , 'unit_of_measurement': '%', }), diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index f7e50d61e2c40..4904829a31c50 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -619,7 +619,6 @@ async def test_conversation_agent( assert entity_entry subentry = mock_config_entry.subentries.get(entity_entry.unique_id) assert subentry - assert entity_entry.original_name == subentry.title device_entry = device_registry.async_get(entity_entry.device_id) assert device_entry diff --git a/tests/components/onkyo/conftest.py b/tests/components/onkyo/conftest.py index c6459a2b1f293..6528168f7232c 100644 --- a/tests/components/onkyo/conftest.py +++ b/tests/components/onkyo/conftest.py @@ -8,9 +8,8 @@ import pytest from homeassistant.components.onkyo.const import DOMAIN -from homeassistant.const import CONF_HOST -from . import RECEIVER_INFO, mock_discovery +from . import RECEIVER_INFO, RECEIVER_INFO_2, mock_discovery from tests.common import MockConfigEntry @@ -24,7 +23,7 @@ def mock_default_discovery() -> Generator[None]: DEVICE_INTERVIEW_TIMEOUT=1, DEVICE_DISCOVERY_TIMEOUT=1, ), - mock_discovery([RECEIVER_INFO]), + mock_discovery([RECEIVER_INFO, RECEIVER_INFO_2]), ): yield @@ -164,7 +163,7 @@ async def write(message: Instruction) -> None: @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Mock a config entry.""" - data = {CONF_HOST: RECEIVER_INFO.host} + data = {"host": RECEIVER_INFO.host} options = { "volume_resolution": 80, "max_volume": 100, diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index df10e26698262..b56ab4b702842 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -1,5 +1,7 @@ """Test Onkyo config flow.""" +from contextlib import AbstractContextManager, nullcontext + from aioonkyo import ReceiverInfo import pytest @@ -15,7 +17,7 @@ from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType, InvalidData +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, SsdpServiceInfo, @@ -26,213 +28,227 @@ from tests.common import MockConfigEntry -def _entry_title(receiver_info: ReceiverInfo) -> str: +def _receiver_display_name(receiver_info: ReceiverInfo) -> str: return f"{receiver_info.model_name} ({receiver_info.host})" -async def test_user_initial_menu(hass: HomeAssistant) -> None: - """Test initial menu.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, +@pytest.mark.usefixtures("mock_setup_entry") +async def test_manual(hass: HomeAssistant) -> None: + """Test successful manual.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - assert init_result["type"] is FlowResultType.MENU - # Check if the values are there, but ignore order - assert not set(init_result["menu_options"]) ^ {"manual", "eiscp_discovery"} - + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" -async def test_manual_valid_host(hass: HomeAssistant) -> None: - """Test valid host entered.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "manual"} ) - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: RECEIVER_INFO.host}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host} ) - assert select_result["step_id"] == "configure_receiver" - assert select_result["description_placeholders"]["name"] == _entry_title( - RECEIVER_INFO + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_receiver" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["THX"], + }, ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == RECEIVER_INFO_2.host + assert result["result"].unique_id == RECEIVER_INFO_2.identifier + assert result["title"] == RECEIVER_INFO_2.model_name -async def test_manual_invalid_host(hass: HomeAssistant) -> None: - """Test invalid host entered.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, +@pytest.mark.parametrize( + ("mock_discovery", "error_reason"), + [ + (mock_discovery(None), "unknown"), + (mock_discovery([]), "cannot_connect"), + (mock_discovery([RECEIVER_INFO]), "cannot_connect"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_manual_recoverable_error( + hass: HomeAssistant, mock_discovery: AbstractContextManager, error_reason: str +) -> None: + """Test manual with a recoverable error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - with mock_discovery([]): - host_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" - assert host_result["step_id"] == "manual" - assert host_result["errors"]["base"] == "cannot_connect" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "manual"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + with mock_discovery: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host} + ) -async def test_manual_valid_host_unexpected_error(hass: HomeAssistant) -> None: - """Test valid host entered.""" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + assert result["errors"] == {"base": error_reason} - init_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_HOST: RECEIVER_INFO_2.host} ) - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_receiver" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["THX"], + }, ) - with mock_discovery(None): - host_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == RECEIVER_INFO_2.host + assert result["result"].unique_id == RECEIVER_INFO_2.identifier + assert result["title"] == RECEIVER_INFO_2.model_name - assert host_result["step_id"] == "manual" - assert host_result["errors"]["base"] == "unknown" +@pytest.mark.usefixtures("mock_setup_entry") +async def test_manual_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test manual with an error.""" + await setup_integration(hass, mock_config_entry) -async def test_eiscp_discovery_no_devices_found(hass: HomeAssistant) -> None: - """Test eiscp discovery with no devices found.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, + DOMAIN, context={"source": SOURCE_USER} ) - with mock_discovery([]): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_devices_found" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "manual"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" -async def test_eiscp_discovery_unexpected_exception(hass: HomeAssistant) -> None: - """Test eiscp discovery with an unexpected 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_HOST: RECEIVER_INFO.host} ) - with mock_discovery(None): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" + assert result["reason"] == "already_configured" @pytest.mark.usefixtures("mock_setup_entry") async def test_eiscp_discovery( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, + hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: - """Test eiscp discovery.""" - mock_config_entry.add_to_hass(hass) + """Test successful eiscp discovery.""" + await setup_integration(hass, mock_config_entry) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, + DOMAIN, context={"source": SOURCE_USER} ) - with mock_discovery([RECEIVER_INFO, RECEIVER_INFO_2]): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "eiscp_discovery"} + ) assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "eiscp_discovery" - assert result["data_schema"] is not None - schema = result["data_schema"].schema - container = schema["device"].container - assert container == {RECEIVER_INFO_2.identifier: _entry_title(RECEIVER_INFO_2)} + devices = result["data_schema"].schema["device"].container + assert devices == { + RECEIVER_INFO_2.identifier: _receiver_display_name(RECEIVER_INFO_2) + } result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"device": RECEIVER_INFO_2.identifier}, + result["flow_id"], user_input={"device": RECEIVER_INFO_2.identifier} ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "configure_receiver" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - "volume_resolution": 200, - "input_sources": ["TV"], - "listening_modes": ["THX"], + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["THX"], }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["host"] == RECEIVER_INFO_2.host + assert result["data"][CONF_HOST] == RECEIVER_INFO_2.host assert result["result"].unique_id == RECEIVER_INFO_2.identifier + assert result["title"] == RECEIVER_INFO_2.model_name +@pytest.mark.parametrize( + ("mock_discovery", "error_reason"), + [ + (mock_discovery(None), "unknown"), + (mock_discovery([]), "no_devices_found"), + (mock_discovery([RECEIVER_INFO]), "no_devices_found"), + ], +) @pytest.mark.usefixtures("mock_setup_entry") -async def test_ssdp_discovery_success(hass: HomeAssistant) -> None: - """Test SSDP discovery with valid host.""" - discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.0.101:8080", - upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, - ssdp_usn="uuid:mock_usn", - ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", - ssdp_st="mock_st", - ) +async def test_eiscp_discovery_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discovery: AbstractContextManager, + error_reason: str, +) -> None: + """Test eiscp discovery with an error.""" + await setup_integration(hass, mock_config_entry) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, + DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_receiver" + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - "volume_resolution": 200, - "input_sources": ["TV"], - "listening_modes": ["THX"], - }, - ) + with mock_discovery: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "eiscp_discovery"} + ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["host"] == RECEIVER_INFO.host - assert result["result"].unique_id == RECEIVER_INFO.identifier + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == error_reason -async def test_ssdp_discovery_already_configured( +@pytest.mark.usefixtures("mock_setup_entry") +async def test_ssdp_discovery( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: - """Test SSDP discovery with already configured device.""" - mock_config_entry.add_to_hass(hass) + """Test successful SSDP discovery.""" + await setup_integration(hass, mock_config_entry) discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.0.101:8080", + ssdp_location=f"http://{RECEIVER_INFO_2.host}:8080", upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", @@ -240,134 +256,95 @@ async def test_ssdp_discovery_already_configured( ) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, + DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=discovery_info ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_receiver" -async def test_ssdp_discovery_host_info_error(hass: HomeAssistant) -> None: - """Test SSDP discovery with host info error.""" - discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.1.100:8080", - upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, - ssdp_usn="uuid:mock_usn", - ssdp_st="mock_st", + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["THX"], + }, ) - with mock_discovery(None): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, - ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == RECEIVER_INFO_2.host + assert result["result"].unique_id == RECEIVER_INFO_2.identifier + assert result["title"] == RECEIVER_INFO_2.model_name - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" +@pytest.mark.parametrize( + ("ssdp_location", "mock_discovery", "error_reason"), + [ + (None, nullcontext(), "unknown"), + ("http://", nullcontext(), "unknown"), + (f"http://{RECEIVER_INFO_2.host}:8080", mock_discovery(None), "unknown"), + (f"http://{RECEIVER_INFO_2.host}:8080", mock_discovery([]), "cannot_connect"), + ( + f"http://{RECEIVER_INFO_2.host}:8080", + mock_discovery([RECEIVER_INFO]), + "cannot_connect", + ), + (f"http://{RECEIVER_INFO.host}:8080", nullcontext(), "already_configured"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_ssdp_discovery_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + ssdp_location: str | None, + mock_discovery: AbstractContextManager, + error_reason: str, +) -> None: + """Test SSDP discovery with an error.""" + await setup_integration(hass, mock_config_entry) -async def test_ssdp_discovery_host_none_info(hass: HomeAssistant) -> None: - """Test SSDP discovery with host info error.""" discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.1.100:8080", + ssdp_location=ssdp_location, upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", + ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", ssdp_st="mock_st", ) - with mock_discovery([]): + with mock_discovery: result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, + DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=discovery_info ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - -async def test_ssdp_discovery_no_location(hass: HomeAssistant) -> None: - """Test SSDP discovery with no location.""" - discovery_info = SsdpServiceInfo( - ssdp_location=None, - upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, - ssdp_usn="uuid:mock_usn", - ssdp_st="mock_st", - ) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" - - -async def test_ssdp_discovery_no_host(hass: HomeAssistant) -> None: - """Test SSDP discovery with no host.""" - discovery_info = SsdpServiceInfo( - ssdp_location="http://", - upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, - ssdp_usn="uuid:mock_usn", - ssdp_st="mock_st", - ) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" - - -async def test_configure_no_resolution(hass: HomeAssistant) -> None: - """Test receiver configure with no resolution set.""" - - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) - - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) - - with pytest.raises(InvalidData): - await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"input_sources": ["TV"]}, - ) + assert result["reason"] == error_reason @pytest.mark.usefixtures("mock_setup_entry") async def test_configure(hass: HomeAssistant) -> None: """Test receiver configure.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, + DOMAIN, context={"source": SOURCE_USER} ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"next_step_id": "manual"}, + result["flow_id"], {"next_step_id": "manual"} ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_HOST: RECEIVER_INFO.host}, + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO.host} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_receiver" + assert result["description_placeholders"]["name"] == _receiver_display_name( + RECEIVER_INFO ) result = await hass.config_entries.flow.async_configure( @@ -378,6 +355,8 @@ async def test_configure(hass: HomeAssistant) -> None: OPTION_LISTENING_MODES: ["THX"], }, ) + + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "configure_receiver" assert result["errors"] == {OPTION_INPUT_SOURCES: "empty_input_source_list"} @@ -389,6 +368,8 @@ async def test_configure(hass: HomeAssistant) -> None: OPTION_LISTENING_MODES: [], }, ) + + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "configure_receiver" assert result["errors"] == {OPTION_LISTENING_MODES: "empty_listening_mode_list"} @@ -400,6 +381,7 @@ async def test_configure(hass: HomeAssistant) -> None: OPTION_LISTENING_MODES: ["THX"], }, ) + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["options"] == { OPTION_VOLUME_RESOLUTION: 200, @@ -409,36 +391,11 @@ async def test_configure(hass: HomeAssistant) -> None: } -async def test_configure_invalid_resolution_set(hass: HomeAssistant) -> None: - """Test receiver configure with invalid resolution.""" - - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) - - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) - - with pytest.raises(InvalidData): - await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"volume_resolution": 42, "input_sources": ["TV"]}, - ) - - @pytest.mark.usefixtures("mock_setup_entry") async def test_reconfigure( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: - """Test the reconfigure config flow.""" + """Test successful reconfigure flow.""" await setup_integration(hass, mock_config_entry) old_host = mock_config_entry.data[CONF_HOST] @@ -449,21 +406,19 @@ async def test_reconfigure( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "manual" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"host": mock_config_entry.data[CONF_HOST]} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: mock_config_entry.data[CONF_HOST]} ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "configure_receiver" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_receiver" - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - user_input={OPTION_VOLUME_RESOLUTION: 200}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={OPTION_VOLUME_RESOLUTION: 200} ) - assert result3["type"] is FlowResultType.ABORT - assert result3["reason"] == "reconfigure_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert mock_config_entry.data[CONF_HOST] == old_host assert mock_config_entry.options[OPTION_VOLUME_RESOLUTION] == 200 @@ -474,24 +429,25 @@ async def test_reconfigure( @pytest.mark.usefixtures("mock_setup_entry") -async def test_reconfigure_new_device( +async def test_reconfigure_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: - """Test the reconfigure config flow with new device.""" + """Test reconfigure flow with an error.""" await setup_integration(hass, mock_config_entry) old_unique_id = mock_config_entry.unique_id result = await mock_config_entry.start_reconfigure_flow(hass) - with mock_discovery([RECEIVER_INFO_2]): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host} - ) - await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "unique_id_mismatch" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" # unique id should remain unchanged assert mock_config_entry.unique_id == old_unique_id @@ -519,6 +475,9 @@ async def test_options_flow( result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index b830d6b774368..7bd84bbcd70d6 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -144,14 +144,49 @@ async def test_setup_minimum( assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 -async def test_setup_encoding( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +@pytest.mark.parametrize( + ("content_text", "content_encoding", "headers", "expected_state"), + [ + # Test setup with non-utf8 encoding + pytest.param( + "tack själv", + "iso-8859-1", + None, + "tack själv", + id="simple_iso88591", + ), + # Test that configured encoding is used when no charset in Content-Type + pytest.param( + "Björk Guðmundsdóttir", + "iso-8859-1", + {"Content-Type": "text/plain"}, # No charset! + "Björk Guðmundsdóttir", + id="fallback_when_no_charset", + ), + # Test that charset in Content-Type overrides configured encoding + pytest.param( + "Björk Guðmundsdóttir", + "utf-8", + {"Content-Type": "text/plain; charset=utf-8"}, + "Björk Guðmundsdóttir", + id="charset_overrides_config", + ), + ], +) +async def test_setup_with_encoding_config( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + content_text: str, + content_encoding: str, + headers: dict[str, str] | None, + expected_state: str, ) -> None: - """Test setup with non-utf8 encoding.""" + """Test setup with encoding configuration in sensor config.""" aioclient_mock.get( "http://localhost", status=HTTPStatus.OK, - content="tack själv".encode(encoding="iso-8859-1"), + content=content_text.encode(content_encoding), + headers=headers, ) assert await async_setup_component( hass, @@ -168,10 +203,10 @@ async def test_setup_encoding( ) await hass.async_block_till_done() assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 - assert hass.states.get("sensor.mysensor").state == "tack själv" + assert hass.states.get("sensor.mysensor").state == expected_state -async def test_setup_auto_encoding_from_content_type( +async def test_setup_with_charset_from_header( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test setup with encoding auto-detected from Content-Type header.""" @@ -188,65 +223,7 @@ async def test_setup_auto_encoding_from_content_type( { SENSOR_DOMAIN: { "name": "mysensor", - # encoding defaults to UTF-8, but should be ignored when charset present - "platform": DOMAIN, - "resource": "http://localhost", - "method": "GET", - } - }, - ) - await hass.async_block_till_done() - assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 - assert hass.states.get("sensor.mysensor").state == "Björk Guðmundsdóttir" - - -async def test_setup_encoding_fallback_no_charset( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test that configured encoding is used when no charset in Content-Type.""" - # No charset in Content-Type header - aioclient_mock.get( - "http://localhost", - status=HTTPStatus.OK, - content="Björk Guðmundsdóttir".encode("iso-8859-1"), - headers={"Content-Type": "text/plain"}, # No charset! - ) - assert await async_setup_component( - hass, - SENSOR_DOMAIN, - { - SENSOR_DOMAIN: { - "name": "mysensor", - "encoding": "iso-8859-1", # This will be used as fallback - "platform": DOMAIN, - "resource": "http://localhost", - "method": "GET", - } - }, - ) - await hass.async_block_till_done() - assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 - assert hass.states.get("sensor.mysensor").state == "Björk Guðmundsdóttir" - - -async def test_setup_charset_overrides_encoding_config( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test that charset in Content-Type overrides configured encoding.""" - # Server sends UTF-8 with correct charset header - aioclient_mock.get( - "http://localhost", - status=HTTPStatus.OK, - content="Björk Guðmundsdóttir".encode(), - headers={"Content-Type": "text/plain; charset=utf-8"}, - ) - assert await async_setup_component( - hass, - SENSOR_DOMAIN, - { - SENSOR_DOMAIN: { - "name": "mysensor", - "encoding": "iso-8859-1", # Config says ISO-8859-1, but charset=utf-8 should win + # No encoding config - should use charset from header. "platform": DOMAIN, "resource": "http://localhost", "method": "GET", @@ -255,7 +232,6 @@ async def test_setup_charset_overrides_encoding_config( ) await hass.async_block_till_done() assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 - # This should work because charset=utf-8 overrides the iso-8859-1 config assert hass.states.get("sensor.mysensor").state == "Björk Guðmundsdóttir" diff --git a/tests/components/smhi/snapshots/test_weather.ambr b/tests/components/smhi/snapshots/test_weather.ambr index 083dcbd6404c2..2df5bb01a3c02 100644 --- a/tests/components/smhi/snapshots/test_weather.ambr +++ b/tests/components/smhi/snapshots/test_weather.ambr @@ -68,7 +68,7 @@ 'precipitation_unit': , 'pressure': 992.4, 'pressure_unit': , - 'supported_features': , + 'supported_features': , 'temperature': 18.4, 'temperature_unit': , 'thunder_probability': 37, @@ -287,7 +287,7 @@ 'precipitation_unit': , 'pressure': 992.4, 'pressure_unit': , - 'supported_features': , + 'supported_features': , 'temperature': 18.4, 'temperature_unit': , 'thunder_probability': 37, @@ -299,3 +299,291 @@ 'wind_speed_unit': , }) # --- +# name: test_twice_daily_forecast_service[load_platforms0] + dict({ + 'weather.smhi_test': dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': 100, + 'condition': 'fog', + 'datetime': '2023-08-07T08:00:00+00:00', + 'humidity': 100, + 'is_daytime': False, + 'precipitation': 0.0, + 'pressure': 992.4, + 'temperature': 18.4, + 'templow': 18.4, + 'wind_bearing': 93, + 'wind_gust_speed': 22.32, + 'wind_speed': 9.0, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-07T12:00:00+00:00', + 'humidity': 96, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 991.7, + 'temperature': 18.4, + 'templow': 17.1, + 'wind_bearing': 114, + 'wind_gust_speed': 32.76, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-08T00:00:00+00:00', + 'humidity': 99, + 'is_daytime': False, + 'precipitation': 0.1, + 'pressure': 987.5, + 'temperature': 18.4, + 'templow': 14.8, + 'wind_bearing': 357, + 'wind_gust_speed': 10.44, + 'wind_speed': 3.96, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'humidity': 97, + 'is_daytime': True, + 'precipitation': 0.3, + 'pressure': 984.1, + 'temperature': 18.4, + 'templow': 12.8, + 'wind_bearing': 183, + 'wind_gust_speed': 27.36, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-09T00:00:00+00:00', + 'humidity': 85, + 'is_daytime': False, + 'precipitation': 0.1, + 'pressure': 995.6, + 'temperature': 18.4, + 'templow': 11.2, + 'wind_bearing': 193, + 'wind_gust_speed': 48.6, + 'wind_speed': 19.8, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'humidity': 95, + 'is_daytime': True, + 'precipitation': 1.1, + 'pressure': 1001.4, + 'temperature': 18.4, + 'templow': 11.1, + 'wind_bearing': 166, + 'wind_gust_speed': 48.24, + 'wind_speed': 18.0, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-10T00:00:00+00:00', + 'humidity': 99, + 'is_daytime': False, + 'precipitation': 3.6, + 'pressure': 1007.8, + 'temperature': 18.4, + 'templow': 10.4, + 'wind_bearing': 200, + 'wind_gust_speed': 28.08, + 'wind_speed': 14.4, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-10T12:00:00+00:00', + 'humidity': 75, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 1011.1, + 'temperature': 18.4, + 'templow': 13.9, + 'wind_bearing': 174, + 'wind_gust_speed': 29.16, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-11T00:00:00+00:00', + 'humidity': 98, + 'is_daytime': False, + 'precipitation': 0.0, + 'pressure': 1012.3, + 'temperature': 18.4, + 'templow': 11.7, + 'wind_bearing': 169, + 'wind_gust_speed': 16.56, + 'wind_speed': 7.56, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-11T12:00:00+00:00', + 'humidity': 69, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 1015.3, + 'temperature': 18.4, + 'templow': 17.6, + 'wind_bearing': 197, + 'wind_gust_speed': 27.36, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 0, + 'condition': 'clear-night', + 'datetime': '2023-08-12T00:00:00+00:00', + 'humidity': 97, + 'is_daytime': False, + 'precipitation': 0.0, + 'pressure': 1015.8, + 'temperature': 18.4, + 'templow': 12.3, + 'wind_bearing': 191, + 'wind_gust_speed': 18.0, + 'wind_speed': 8.64, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-12T12:00:00+00:00', + 'humidity': 82, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 1014.0, + 'temperature': 18.4, + 'templow': 17.0, + 'wind_bearing': 225, + 'wind_gust_speed': 28.08, + 'wind_speed': 8.64, + }), + dict({ + 'cloud_coverage': 12, + 'condition': 'clear-night', + 'datetime': '2023-08-13T00:00:00+00:00', + 'humidity': 92, + 'is_daytime': False, + 'precipitation': 0.0, + 'pressure': 1013.9, + 'temperature': 18.4, + 'templow': 13.6, + 'wind_bearing': 233, + 'wind_gust_speed': 20.16, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-13T12:00:00+00:00', + 'humidity': 59, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 1013.6, + 'temperature': 20.0, + 'templow': 18.4, + 'wind_bearing': 234, + 'wind_gust_speed': 35.64, + 'wind_speed': 14.76, + }), + dict({ + 'cloud_coverage': 50, + 'condition': 'partlycloudy', + 'datetime': '2023-08-14T00:00:00+00:00', + 'humidity': 91, + 'is_daytime': False, + 'precipitation': 0.0, + 'pressure': 1015.2, + 'temperature': 18.4, + 'templow': 13.5, + 'wind_bearing': 227, + 'wind_gust_speed': 23.4, + 'wind_speed': 10.8, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'partlycloudy', + 'datetime': '2023-08-14T12:00:00+00:00', + 'humidity': 56, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 1015.3, + 'temperature': 20.8, + 'templow': 18.4, + 'wind_bearing': 216, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-15T00:00:00+00:00', + 'humidity': 93, + 'is_daytime': False, + 'precipitation': 1.2, + 'pressure': 1014.9, + 'temperature': 18.4, + 'templow': 14.3, + 'wind_bearing': 196, + 'wind_gust_speed': 22.32, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 88, + 'condition': 'partlycloudy', + 'datetime': '2023-08-15T12:00:00+00:00', + 'humidity': 64, + 'is_daytime': True, + 'precipitation': 2.4, + 'pressure': 1014.3, + 'temperature': 20.4, + 'templow': 18.4, + 'wind_bearing': 226, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 38, + 'condition': 'clear-night', + 'datetime': '2023-08-16T00:00:00+00:00', + 'humidity': 93, + 'is_daytime': False, + 'precipitation': 1.2, + 'pressure': 1014.9, + 'temperature': 18.4, + 'templow': 13.8, + 'wind_bearing': 228, + 'wind_gust_speed': 21.24, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-16T12:00:00+00:00', + 'humidity': 61, + 'is_daytime': True, + 'precipitation': 1.2, + 'pressure': 1014.0, + 'temperature': 20.2, + 'templow': 18.4, + 'wind_bearing': 233, + 'wind_gust_speed': 33.48, + 'wind_speed': 14.04, + }), + ]), + }), + }) +# --- diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index 5cf8c2ae41d73..9acacb10ffa59 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -473,3 +473,23 @@ async def test_forecast_service( return_response=True, ) assert response == snapshot + + +@pytest.mark.parametrize( + "load_platforms", + [[Platform.WEATHER]], +) +async def test_twice_daily_forecast_service( + hass: HomeAssistant, + load_int: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test forecast service.""" + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + {"entity_id": ENTITY_ID, "type": "twice_daily"}, + blocking=True, + return_response=True, + ) + assert response == snapshot diff --git a/tests/components/tesla_fleet/__init__.py b/tests/components/tesla_fleet/__init__.py index c51cd83ee66c0..a43ec14fc51f2 100644 --- a/tests/components/tesla_fleet/__init__.py +++ b/tests/components/tesla_fleet/__init__.py @@ -8,7 +8,7 @@ ClientCredential, async_import_client_credential, ) -from homeassistant.components.tesla_fleet.const import CLIENT_ID, DOMAIN +from homeassistant.components.tesla_fleet.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -28,7 +28,7 @@ async def setup_platform( await async_import_client_credential( hass, DOMAIN, - ClientCredential(CLIENT_ID, "", "Home Assistant"), + ClientCredential("CLIENT_ID", "CLIENT_SECRET", "Home Assistant"), DOMAIN, ) diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index d9016d18def08..632d05ce93140 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -148,6 +148,10 @@ Platform.SELECT, Platform.SWITCH, ], + "ydkt_dolceclima_unsupported": [ + # https://github.com/orgs/home-assistant/discussions/288 + # unsupported device - no platforms + ], "wk_wifi_smart_gas_boiler_thermostat": [ # https://github.com/orgs/home-assistant/discussions/243 Platform.CLIMATE, diff --git a/tests/components/tuya/fixtures/ydkt_dolceclima_unsupported.json b/tests/components/tuya/fixtures/ydkt_dolceclima_unsupported.json new file mode 100644 index 0000000000000..f50aab00a2666 --- /dev/null +++ b/tests/components/tuya/fixtures/ydkt_dolceclima_unsupported.json @@ -0,0 +1,23 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "mock_terminal_id", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "mock_device_id", + "name": "DOLCECLIMA 10 HP WIFI", + "category": "ydkt", + "product_id": "jevroj5aguwdbs2e", + "product_name": "DOLCECLIMA 10 HP WIFI", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-09T18:39:25+00:00", + "create_time": "2025-07-09T18:39:25+00:00", + "update_time": "2025-07-09T18:39:25+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr new file mode 100644 index 0000000000000..084e9a84401e5 --- /dev/null +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -0,0 +1,36 @@ +# serializer version: 1 +# name: test_unsupported_device[ydkt_dolceclima_unsupported] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'mock_device_id', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'DOLCECLIMA 10 HP WIFI (unsupported)', + 'model_id': 'jevroj5aguwdbs2e', + 'name': 'DOLCECLIMA 10 HP WIFI', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- diff --git a/tests/components/tuya/test_humidifier.py b/tests/components/tuya/test_humidifier.py index f4cd264a03c93..d4996bcd32a9b 100644 --- a/tests/components/tuya/test_humidifier.py +++ b/tests/components/tuya/test_humidifier.py @@ -8,9 +8,16 @@ from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice +from homeassistant.components.humidifier import ( + DOMAIN as HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from . import DEVICE_MOCKS, initialize_entry @@ -54,3 +61,180 @@ async def test_platform_setup_no_discovery( assert not er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_arete_two_12l_dehumidifier_air_purifier"], +) +async def test_turn_on( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test turn on service.""" + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": entity_id}, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "switch", "value": True}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_arete_two_12l_dehumidifier_air_purifier"], +) +async def test_turn_off( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test turn off service.""" + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": entity_id}, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "switch", "value": False}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_arete_two_12l_dehumidifier_air_purifier"], +) +async def test_set_humidity( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set humidity service.""" + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + { + "entity_id": entity_id, + "humidity": 50, + }, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "dehumidify_set_value", "value": 50}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_smart_dry_plus"], +) +async def test_turn_on_unsupported( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test turn on service (not supported by this device).""" + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": entity_id}, + blocking=True, + ) + assert err.value.translation_key == "action_dpcode_not_found" + assert err.value.translation_placeholders == { + "expected": "['switch', 'switch_spray']", + "available": ("[]"), + } + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_smart_dry_plus"], +) +async def test_turn_off_unsupported( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test turn off service (not supported by this device).""" + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": entity_id}, + blocking=True, + ) + assert err.value.translation_key == "action_dpcode_not_found" + assert err.value.translation_placeholders == { + "expected": "['switch', 'switch_spray']", + "available": ("[]"), + } + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_smart_dry_plus"], +) +async def test_set_humidity_unsupported( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set humidity service (not supported by this device).""" + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + { + "entity_id": entity_id, + "humidity": 50, + }, + blocking=True, + ) + assert err.value.translation_key == "action_dpcode_not_found" + assert err.value.translation_placeholders == { + "expected": "['dehumidify_set_value']", + "available": ("[]"), + } diff --git a/tests/components/tuya/test_init.py b/tests/components/tuya/test_init.py new file mode 100644 index 0000000000000..8fbf6fb4e3b03 --- /dev/null +++ b/tests/components/tuya/test_init.py @@ -0,0 +1,49 @@ +"""Test Tuya initialization.""" + +from __future__ import annotations + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize("mock_device_code", ["ydkt_dolceclima_unsupported"]) +async def test_unsupported_device( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test unsupported device.""" + + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + # Device is registered + assert ( + dr.async_entries_for_config_entry(device_registry, mock_config_entry.entry_id) + == snapshot + ) + # No entities registered + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + # Information log entry added + assert ( + "Device DOLCECLIMA 10 HP WIFI (mock_device_id) has been ignored" + " as it does not provide any standard instructions (status, status_range" + " and function are all empty) - see " + "https://github.com/tuya/tuya-device-sharing-sdk/issues/11" in caplog.text + ) diff --git a/tests/components/tuya/test_number.py b/tests/components/tuya/test_number.py index 7da514964aa8a..b6c7b1f6de51b 100644 --- a/tests/components/tuya/test_number.py +++ b/tests/components/tuya/test_number.py @@ -8,9 +8,11 @@ from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from . import DEVICE_MOCKS, initialize_entry @@ -53,3 +55,76 @@ async def test_platform_setup_no_discovery( assert not er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["mal_alarm_host"], +) +async def test_set_value( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set value.""" + entity_id = "number.multifunction_alarm_arm_delay" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + "entity_id": entity_id, + "value": 18, + }, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "delay_set", "value": 18}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["mal_alarm_host"], +) +async def test_set_value_no_function( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set value when no function available.""" + + # Mock a device with delay_set in status but not in function or status_range + mock_device.function.pop("delay_set") + mock_device.status_range.pop("delay_set") + + entity_id = "number.multifunction_alarm_arm_delay" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + "entity_id": entity_id, + "value": 18, + }, + blocking=True, + ) + assert err.value.translation_key == "action_dpcode_not_found" + assert err.value.translation_placeholders == { + "expected": "['delay_set']", + "available": ( + "['alarm_delay_time', 'alarm_time', 'master_mode', 'master_state', " + "'muffling', 'sub_admin', 'sub_class', 'switch_alarm_light', " + "'switch_alarm_propel', 'switch_alarm_sound', 'switch_kb_light', " + "'switch_kb_sound', 'switch_mode_sound']" + ), + } diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 1163da4971c99..3c07869d5b735 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -538,6 +538,24 @@ def zcombo_smoke_co_alarm_state_fixture() -> NodeDataType: ) +@pytest.fixture(name="nabu_casa_zwa2_state") +def nabu_casa_zwa2_state_fixture() -> NodeDataType: + """Load node with fixture data for Nabu Casa ZWA-2.""" + return cast( + NodeDataType, + load_json_object_fixture("nabu_casa_zwa2_state.json", DOMAIN), + ) + + +@pytest.fixture(name="nabu_casa_zwa2_legacy_state") +def nabu_casa_zwa2_legacy_state_fixture() -> NodeDataType: + """Load node with fixture data for Nabu Casa ZWA-2 (legacy firmware).""" + return cast( + NodeDataType, + load_json_object_fixture("nabu_casa_zwa2_legacy_state.json", DOMAIN), + ) + + # model fixtures @@ -1358,3 +1376,23 @@ def zcombo_smoke_co_alarm_fixture( node = Node(client, zcombo_smoke_co_alarm_state) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="nabu_casa_zwa2") +def nabu_casa_zwa2_fixture( + client: MagicMock, nabu_casa_zwa2_state: NodeDataType +) -> Node: + """Load node for Nabu Casa ZWA-2.""" + node = Node(client, nabu_casa_zwa2_state) + client.driver.controller.nodes[node.node_id] = node + return node + + +@pytest.fixture(name="nabu_casa_zwa2_legacy") +def nabu_casa_zwa2_legacy_fixture( + client: MagicMock, nabu_casa_zwa2_legacy_state: NodeDataType +) -> Node: + """Load node for Nabu Casa ZWA-2 (legacy firmware).""" + node = Node(client, nabu_casa_zwa2_legacy_state) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json new file mode 100644 index 0000000000000..8ea8cdbd00911 --- /dev/null +++ b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json @@ -0,0 +1,231 @@ +{ + "nodeId": 1, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "manufacturerId": 1126, + "productId": 1, + "productType": 1, + "firmwareVersion": "1.0", + "deviceConfig": { + "filename": "/data/db/devices/0x0466/zwa-2.json", + "isEmbedded": true, + "manufacturer": "Nabu Casa", + "manufacturerId": 1126, + "label": "NC-ZWA-9734", + "description": "Home Assistant Connect ZWA-2", + "devices": [ + { + "productType": 1, + "productId": 1 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false + }, + "label": "NC-ZWA-9734", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "deviceClass": { + "basic": { + "key": 2, + "label": "Static Controller" + }, + "generic": { + "key": 2, + "label": "Static Controller" + }, + "specific": { + "key": 1, + "label": "PC Controller" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0466:0x0001:0x0001:1.0", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0 + }, + "isControllerNode": true, + "keepAwake": false, + "protocol": 0, + "sdkVersion": "7.23.1", + "values": [ + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 2, + "propertyName": "currentColor", + "propertyKeyName": "Red", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Red channel.", + "label": "Current value (Red)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 255 + }, + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 3, + "propertyName": "currentColor", + "propertyKeyName": "Green", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Green channel.", + "label": "Current value (Green)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 227 + }, + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 4, + "propertyName": "currentColor", + "propertyKeyName": "Blue", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Blue channel.", + "label": "Current value (Blue)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 181 + }, + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyName": "currentColor", + "ccVersion": 0, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Current color", + "stateful": true, + "secret": false + }, + "value": { + "red": 255, + "green": 227, + "blue": 181 + } + }, + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyName": "targetColor", + "ccVersion": 0, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Target color", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "hexColor", + "propertyName": "hexColor", + "ccVersion": 0, + "metadata": { + "type": "color", + "readable": true, + "writeable": true, + "label": "RGB Color", + "valueChangeOptions": ["transitionDuration"], + "minLength": 6, + "maxLength": 7, + "stateful": true, + "secret": false + }, + "value": "ffe3b5" + }, + { + "commandClass": 112, + "commandClassName": "Configuration", + "property": 0, + "propertyName": "enableTiltIndicator", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Enable Tilt Indicator", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false + }, + "value": 1 + } + ], + "endpoints": [ + { + "nodeId": 1, + "index": 0, + "deviceClass": { + "basic": { + "key": 2, + "label": "Static Controller" + }, + "generic": { + "key": 2, + "label": "Static Controller" + }, + "specific": { + "key": 1, + "label": "PC Controller" + } + }, + "commandClasses": [] + } + ] +} diff --git a/tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json new file mode 100644 index 0000000000000..e0c57462440e6 --- /dev/null +++ b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json @@ -0,0 +1,146 @@ +{ + "nodeId": 1, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "manufacturerId": 1126, + "productId": 1, + "productType": 1, + "firmwareVersion": "1.0", + "deviceConfig": { + "filename": "/home/dominic/Repositories/zwavejs2mqtt/store/.config-db/devices/0x0466/zwa-2.json", + "isEmbedded": true, + "manufacturer": "Nabu Casa", + "manufacturerId": 1126, + "label": "NC-ZWA-9734", + "description": "Home Assistant Connect ZWA-2", + "devices": [ + { + "productType": 1, + "productId": 1 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false + }, + "label": "NC-ZWA-9734", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "deviceClass": { + "basic": { + "key": 2, + "label": "Static Controller" + }, + "generic": { + "key": 2, + "label": "Static Controller" + }, + "specific": { + "key": 1, + "label": "PC Controller" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0466:0x0001:0x0001:1.0", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0 + }, + "isControllerNode": true, + "keepAwake": false, + "protocol": 0, + "sdkVersion": "7.23.1", + "values": [ + { + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": true + }, + { + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": true + }, + { + "commandClass": 112, + "commandClassName": "Configuration", + "property": 0, + "propertyName": "enableTiltIndicator", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Enable Tilt Indicator", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false + }, + "value": 1 + } + ], + "endpoints": [ + { + "nodeId": 1, + "index": 0, + "deviceClass": { + "basic": { + "key": 2, + "label": "Static Controller" + }, + "generic": { + "key": 2, + "label": "Static Controller" + }, + "specific": { + "key": 1, + "label": "PC Controller" + } + }, + "commandClasses": [] + } + ] +} diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 44133db03ac66..9109d6a4048a3 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -495,3 +495,67 @@ async def test_aeotec_smart_switch_7( entity_entry = entity_registry.async_get(state.entity_id) assert entity_entry assert entity_entry.entity_category is EntityCategory.CONFIG + + +async def test_nabu_casa_zwa2( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + nabu_casa_zwa2: Node, + integration: MockConfigEntry, +) -> None: + """Test ZWA-2 discovery.""" + state = hass.states.get("light.home_assistant_connect_zwa_2_led") + assert state, "The LED indicator should be enabled by default" + + entry = entity_registry.async_get(state.entity_id) + assert entry, "Entity for the LED indicator not found" + + assert entry.capabilities.get(ATTR_SUPPORTED_COLOR_MODES) == [ + ColorMode.ONOFF, + ], "The LED indicator should be an ON/OFF light" + + assert not entry.disabled, "The entity should be enabled by default" + + assert entry.entity_category is EntityCategory.CONFIG, ( + "The LED indicator should be configuration" + ) + + # Test that the entity name is properly set to "LED" + assert entry.original_name == "LED", ( + "The LED entity should have the original name 'LED'" + ) + assert state.attributes["friendly_name"] == "Home Assistant Connect ZWA-2 LED", ( + "The LED should have the correct friendly name" + ) + + +async def test_nabu_casa_zwa2_legacy( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + nabu_casa_zwa2_legacy: Node, + integration: MockConfigEntry, +) -> None: + """Test ZWA-2 discovery with legacy firmware.""" + state = hass.states.get("light.home_assistant_connect_zwa_2_led") + assert state, "The LED indicator should be enabled by default" + + entry = entity_registry.async_get(state.entity_id) + assert entry, "Entity for the LED indicator not found" + + assert entry.capabilities.get(ATTR_SUPPORTED_COLOR_MODES) == [ + ColorMode.HS, + ], "The LED indicator should be a color light" + + assert not entry.disabled, "The entity should be enabled by default" + + assert entry.entity_category is EntityCategory.CONFIG, ( + "The LED indicator should be configuration" + ) + + # Test that the entity name is properly set to "LED" + assert entry.original_name == "LED", ( + "The LED entity should have the original name 'LED'" + ) + assert state.attributes["friendly_name"] == "Home Assistant Connect ZWA-2 LED", ( + "The LED should have the correct friendly name" + )