diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index 69ae3eb65bd4b4..675c2d10fea37c 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -92,7 +92,11 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.network import is_cloud_connection +from homeassistant.helpers.network import ( + NoURLAvailableError, + get_url, + is_cloud_connection, +) from homeassistant.util.network import is_local from . import indieauth @@ -125,11 +129,18 @@ class WellKnownOAuthInfoView(HomeAssistantView): async def get(self, request: web.Request) -> web.Response: """Return the well known OAuth2 authorization info.""" + hass = request.app[KEY_HASS] + # Some applications require absolute urls, so we prefer using the + # current requests url if possible, with fallback to a relative url. + try: + url_prefix = get_url(hass, require_current_request=True) + except NoURLAvailableError: + url_prefix = "" return self.json( { - "authorization_endpoint": "/auth/authorize", - "token_endpoint": "/auth/token", - "revocation_endpoint": "/auth/revoke", + "authorization_endpoint": f"{url_prefix}/auth/authorize", + "token_endpoint": f"{url_prefix}/auth/token", + "revocation_endpoint": f"{url_prefix}/auth/revoke", "response_types_supported": ["code"], "service_documentation": ( "https://developers.home-assistant.io/docs/auth_api" diff --git a/homeassistant/components/conversation/icons.json b/homeassistant/components/conversation/icons.json index 658783f9ae26f9..55bacf838a8663 100644 --- a/homeassistant/components/conversation/icons.json +++ b/homeassistant/components/conversation/icons.json @@ -1,4 +1,9 @@ { + "entity_component": { + "_": { + "default": "mdi:forum-outline" + } + }, "services": { "process": { "service": "mdi:message-processing" diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 36db24ce5453d3..8101f8c8b5f6f6 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@home-assistant/core", "@synesthesiam", "@arturpragacz"], "dependencies": ["http", "intent"], "documentation": "https://www.home-assistant.io/integrations/conversation", - "integration_type": "system", + "integration_type": "entity", "quality_scale": "internal", "requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.3"] } diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 9ad00c69ab140b..167e1f70c2c5b4 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -152,24 +152,28 @@ native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=1, ), EcoWittSensorTypes.RAIN_COUNT_INCHES: SensorEntityDescription( key="RAIN_COUNT_INCHES", native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, ), EcoWittSensorTypes.RAIN_RATE_MM: SensorEntityDescription( key="RAIN_RATE_MM", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + suggested_display_precision=1, ), EcoWittSensorTypes.RAIN_RATE_INCHES: SensorEntityDescription( key="RAIN_RATE_INCHES", native_unit_of_measurement=UnitOfVolumetricFlux.INCHES_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + suggested_display_precision=2, ), EcoWittSensorTypes.LIGHTNING_DISTANCE_KM: SensorEntityDescription( key="LIGHTNING_DISTANCE_KM", diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 0e4a2c40d4657f..92f9266859bd3c 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==40.1.0", + "aioesphomeapi==40.2.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.3.0" ], diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 77cd7ccb35adc9..c14bc1e67074cb 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -12,7 +12,7 @@ "mqtt_missing_mac": "Missing MAC address in MQTT properties.", "mqtt_missing_api": "Missing API port in MQTT properties.", "mqtt_missing_ip": "Missing IP address in MQTT properties.", - "mqtt_missing_payload": "Missing MQTT Payload.", + "mqtt_missing_payload": "Missing MQTT payload.", "name_conflict_migrated": "The configuration for `{name}` has been migrated to a new device with MAC address `{mac}` from `{existing_mac}`.", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "reauth_unique_id_changed": "**Re-authentication of `{name}` was aborted** because the address `{host}` points to a different device: `{unexpected_device_name}` (MAC: `{unexpected_mac}`) instead of the expected one (MAC: `{expected_mac}`).", @@ -91,7 +91,7 @@ "subscribe_logs": "Subscribe to logs from the device." }, "data_description": { - "allow_service_calls": "When enabled, ESPHome devices can perform Home Assistant actions, such as calling services or sending events. Only enable this if you trust the device.", + "allow_service_calls": "When enabled, ESPHome devices can perform Home Assistant actions or send events. Only enable this if you trust the device.", "subscribe_logs": "When enabled, the device will send logs to Home Assistant and you can view them in the logs panel." } } @@ -154,7 +154,7 @@ "description": "To improve Bluetooth reliability and performance, we highly recommend updating {name} with ESPHome {version} or later. When updating the device from ESPHome earlier than 2022.12.0, it is recommended to use a serial cable instead of an over-the-air update to take advantage of the new partition scheme." }, "api_password_deprecated": { - "title": "API Password deprecated on {name}", + "title": "API password deprecated on {name}", "description": "The API password for ESPHome is deprecated and the use of an API encryption key is recommended instead.\n\nRemove the API password and add an encryption key to your ESPHome device to resolve this issue." }, "service_calls_not_allowed": { @@ -193,10 +193,10 @@ "message": "Error communicating with the device {device_name}: {error}" }, "error_compiling": { - "message": "Error compiling {configuration}; Try again in ESPHome dashboard for more information." + "message": "Error compiling {configuration}. Try again in ESPHome dashboard for more information." }, "error_uploading": { - "message": "Error during OTA (Over-The-Air) of {configuration}; Try again in ESPHome dashboard for more information." + "message": "Error during OTA (Over-The-Air) update of {configuration}. Try again in ESPHome dashboard for more information." }, "ota_in_progress": { "message": "An OTA (Over-The-Air) update is already in progress for {configuration}." diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 393fe480057385..96855097b8b3f2 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -37,14 +37,14 @@ }, "issue_addon_detached_addon_missing": { "title": "Missing repository for an installed add-on", - "description": "Repository for add-on {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the supervisor may not be able to build/download the resources required.\n\nPlease check the [add-on's documentation]({addon_url}) for installation instructions and add the repository to the store." + "description": "Repository for add-on {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the Home Assistant Supervisor may not be able to build/download the resources required.\n\nPlease check the [add-on's documentation]({addon_url}) for installation instructions and add the repository to the store." }, "issue_addon_detached_addon_removed": { "title": "Installed add-on has been removed from repository", "fix_flow": { "step": { "addon_execute_remove": { - "description": "Add-on {addon} has been removed from the repository it was installed from. This means it will not get updates, and backups may not be restored correctly as the supervisor may not be able to build/download the resources required.\n\nSelecting **Submit** will uninstall this deprecated add-on. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to." + "description": "Add-on {addon} has been removed from the repository it was installed from. This means it will not get updates, and backups may not be restored correctly as the Home Assistant Supervisor may not be able to build/download the resources required.\n\nSelecting **Submit** will uninstall this deprecated add-on. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to." } }, "abort": { diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 5ea0d217f141f5..40c27762f00816 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.79", "babel==2.15.0"] + "requirements": ["holidays==0.80", "babel==2.15.0"] } diff --git a/homeassistant/components/home_connect/application_credentials.py b/homeassistant/components/home_connect/application_credentials.py index 20a3a211b6aa02..d66255e6810856 100644 --- a/homeassistant/components/home_connect/application_credentials.py +++ b/homeassistant/components/home_connect/application_credentials.py @@ -12,13 +12,3 @@ async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationSe authorize_url=OAUTH2_AUTHORIZE, token_url=OAUTH2_TOKEN, ) - - -async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: - """Return description placeholders for the credentials dialog.""" - return { - "developer_dashboard_url": "https://developer.home-connect.com/", - "applications_url": "https://developer.home-connect.com/applications", - "register_application_url": "https://developer.home-connect.com/application/add", - "redirect_url": "https://my.home-assistant.io/redirect/oauth", - } diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 81f785b55aeacf..92ede6a5a3afc8 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -659,17 +659,3 @@ def refreshed_too_often_recently(self, appliance_ha_id: str) -> bool: ) return False - - async def reset_execution_tracker(self, appliance_ha_id: str) -> None: - """Reset the execution tracker for a specific appliance.""" - self._execution_tracker.pop(appliance_ha_id, None) - appliance_info = await self.client.get_specific_appliance(appliance_ha_id) - - appliance_data = await self._get_appliance_data( - appliance_info, self.data.get(appliance_info.ha_id) - ) - self.data[appliance_ha_id].update(appliance_data) - for listener, context in self._special_listeners.values(): - if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED not in context: - listener() - self._call_all_event_listeners_for_appliance(appliance_ha_id) diff --git a/homeassistant/components/home_connect/repairs.py b/homeassistant/components/home_connect/repairs.py deleted file mode 100644 index 21c6775e549bca..00000000000000 --- a/homeassistant/components/home_connect/repairs.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Repairs flows for Home Connect.""" - -from typing import cast - -import voluptuous as vol - -from homeassistant import data_entry_flow -from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir - -from .coordinator import HomeConnectConfigEntry - - -class EnableApplianceUpdatesFlow(RepairsFlow): - """Handler for enabling appliance's updates after being refreshed too many times.""" - - async def async_step_init( - self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: - """Handle the first step of a fix flow.""" - return await self.async_step_confirm() - - async def async_step_confirm( - self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: - """Handle the confirm step of a fix flow.""" - if user_input is not None: - assert self.data - entry = self.hass.config_entries.async_get_entry( - cast(str, self.data["entry_id"]) - ) - assert entry - entry = cast(HomeConnectConfigEntry, entry) - await entry.runtime_data.reset_execution_tracker( - cast(str, self.data["appliance_ha_id"]) - ) - return self.async_create_entry(data={}) - - issue_registry = ir.async_get(self.hass) - description_placeholders = None - if issue := issue_registry.async_get_issue(self.handler, self.issue_id): - description_placeholders = issue.translation_placeholders - - return self.async_show_form( - step_id="confirm", - data_schema=vol.Schema({}), - description_placeholders=description_placeholders, - ) - - -async def async_create_fix_flow( - hass: HomeAssistant, - issue_id: str, - data: dict[str, str | int | float | None] | None, -) -> RepairsFlow: - """Create flow.""" - if issue_id.startswith("home_connect_too_many_connected_paired_events"): - return EnableApplianceUpdatesFlow() - return ConfirmRepairFlow() diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index ef4fdadb24c41f..e9ea92c78e82da 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.2.16"], + "requirements": ["aiohomekit==3.2.17"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/husqvarna_automower_ble/config_flow.py b/homeassistant/components/husqvarna_automower_ble/config_flow.py index 7d1977f930c1aa..fdca16a2765fb8 100644 --- a/homeassistant/components/husqvarna_automower_ble/config_flow.py +++ b/homeassistant/components/husqvarna_automower_ble/config_flow.py @@ -21,6 +21,21 @@ from .const import DOMAIN, LOGGER +BLUETOOTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_PIN): str, + } +) + +USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_ADDRESS): str, + vol.Required(CONF_PIN): str, + } +) + +REAUTH_SCHEMA = BLUETOOTH_SCHEMA + def _is_supported(discovery_info: BluetoothServiceInfo): """Check if device is supported.""" @@ -78,6 +93,10 @@ async def async_step_bluetooth( if not _is_supported(discovery_info): return self.async_abort(reason="no_devices_found") + self.context["title_placeholders"] = { + "name": discovery_info.name, + "address": discovery_info.address, + } self.address = discovery_info.address await self.async_set_unique_id(self.address) self._abort_if_unique_id_configured() @@ -100,12 +119,7 @@ async def async_step_bluetooth_confirm( return self.async_show_form( step_id="bluetooth_confirm", data_schema=self.add_suggested_values_to_schema( - vol.Schema( - { - vol.Required(CONF_PIN): str, - }, - ), - user_input, + BLUETOOTH_SCHEMA, user_input ), description_placeholders={"name": self.mower_name or self.address}, errors=errors, @@ -129,15 +143,7 @@ async def async_step_user( return self.async_show_form( step_id="user", - data_schema=self.add_suggested_values_to_schema( - vol.Schema( - { - vol.Required(CONF_ADDRESS): str, - vol.Required(CONF_PIN): str, - }, - ), - user_input, - ), + data_schema=self.add_suggested_values_to_schema(USER_SCHEMA, user_input), errors=errors, ) @@ -184,7 +190,24 @@ async def check_mower( title = await self.probe_mower(device) if title is None: - return self.async_abort(reason="cannot_connect") + if self.source == SOURCE_BLUETOOTH: + return self.async_show_form( + step_id="bluetooth_confirm", + data_schema=BLUETOOTH_SCHEMA, + description_placeholders={"name": self.address}, + errors={"base": "cannot_connect"}, + ) + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + USER_SCHEMA, + { + CONF_ADDRESS: self.address, + CONF_PIN: self.pin, + }, + ), + errors={"base": "cannot_connect"}, + ) self.mower_name = title try: @@ -209,11 +232,7 @@ async def check_mower( if self.source == SOURCE_BLUETOOTH: return self.async_show_form( step_id="bluetooth_confirm", - data_schema=vol.Schema( - { - vol.Required(CONF_PIN): str, - }, - ), + data_schema=BLUETOOTH_SCHEMA, description_placeholders={ "name": self.mower_name or self.address }, @@ -230,13 +249,7 @@ async def check_mower( return self.async_show_form( step_id="user", data_schema=self.add_suggested_values_to_schema( - vol.Schema( - { - vol.Required(CONF_ADDRESS): str, - vol.Required(CONF_PIN): str, - }, - ), - suggested_values, + USER_SCHEMA, suggested_values ), errors=errors, ) @@ -312,12 +325,7 @@ async def async_step_reauth_confirm( return self.async_show_form( step_id="reauth_confirm", data_schema=self.add_suggested_values_to_schema( - vol.Schema( - { - vol.Required(CONF_PIN): str, - }, - ), - {CONF_PIN: self.pin}, + REAUTH_SCHEMA, {CONF_PIN: self.pin} ), description_placeholders={"name": self.mower_name}, errors=errors, diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index 30df24ea854ffd..97e0d316ff55f8 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -132,7 +132,7 @@ def __init__( self._store = store self._calendar = calendar self._calendar_lock = asyncio.Lock() - self._attr_name = name.capitalize() + self._attr_name = name self._attr_unique_id = unique_id def _new_todo_store(self) -> TodoStore: diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index e05fddb996fc46..ad1530bef5e2d4 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -1,5 +1,6 @@ """Support for Lutron Caseta shades.""" +from enum import Enum from typing import Any from homeassistant.components.cover import ( @@ -17,6 +18,14 @@ from .models import LutronCasetaConfigEntry +class ShadeMovementDirection(Enum): + """Enum for shade movement direction.""" + + OPENING = "opening" + CLOSING = "closing" + STOPPED = "stopped" + + class LutronCasetaShade(LutronCasetaUpdatableEntity, CoverEntity): """Representation of a Lutron shade with open/close functionality.""" @@ -27,6 +36,8 @@ class LutronCasetaShade(LutronCasetaUpdatableEntity, CoverEntity): | CoverEntityFeature.SET_POSITION ) _attr_device_class = CoverDeviceClass.SHADE + _previous_position: int | None = None + _movement_direction: ShadeMovementDirection | None = None @property def is_closed(self) -> bool: @@ -38,19 +49,50 @@ def current_cover_position(self) -> int: """Return the current position of cover.""" return self._device["current_state"] + def _handle_bridge_update(self) -> None: + """Handle updated data from the bridge and track movement direction.""" + current_position = self.current_cover_position + + # Track movement direction based on position changes or endpoint status + if self._previous_position is not None: + if current_position > self._previous_position or current_position >= 100: + # Moving up or at fully open + self._movement_direction = ShadeMovementDirection.OPENING + elif current_position < self._previous_position or current_position <= 0: + # Moving down or at fully closed + self._movement_direction = ShadeMovementDirection.CLOSING + else: + # Stopped + self._movement_direction = ShadeMovementDirection.STOPPED + + self._previous_position = current_position + super()._handle_bridge_update() + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._smartbridge.lower_cover(self.device_id) + # Use set_value to avoid the stuttering issue + await self._smartbridge.set_value(self.device_id, 0) await self.async_update() self.async_write_ha_state() async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" + # Send appropriate directional command before stop to ensure it works correctly + # Use tracked direction if moving, otherwise use position-based heuristic + if self._movement_direction == ShadeMovementDirection.OPENING or ( + self._movement_direction in (ShadeMovementDirection.STOPPED, None) + and self.current_cover_position >= 50 + ): + await self._smartbridge.raise_cover(self.device_id) + else: + await self._smartbridge.lower_cover(self.device_id) + await self._smartbridge.stop_cover(self.device_id) async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._smartbridge.raise_cover(self.device_id) + # Use set_value to avoid the stuttering issue + await self._smartbridge.set_value(self.device_id, 100) await self.async_update() self.async_write_ha_state() diff --git a/homeassistant/components/lutron_caseta/entity.py b/homeassistant/components/lutron_caseta/entity.py index 5ab211ed87b756..8cae22f5042ed7 100644 --- a/homeassistant/components/lutron_caseta/entity.py +++ b/homeassistant/components/lutron_caseta/entity.py @@ -65,7 +65,11 @@ def __init__(self, device: dict[str, Any], data: LutronCasetaData) -> None: async def async_added_to_hass(self) -> None: """Register callbacks.""" - self._smartbridge.add_subscriber(self.device_id, self.async_write_ha_state) + self._smartbridge.add_subscriber(self.device_id, self._handle_bridge_update) + + def _handle_bridge_update(self) -> None: + """Handle updated data from the bridge.""" + self.async_write_ha_state() def _handle_none_serial(self, serial: str | int | None) -> str | int: """Handle None serial returned by RA3 and QSX processors.""" diff --git a/homeassistant/components/miele/coordinator.py b/homeassistant/components/miele/coordinator.py index 98f5c9f8b1cfd8..b3eb1185bd1af9 100644 --- a/homeassistant/components/miele/coordinator.py +++ b/homeassistant/components/miele/coordinator.py @@ -2,12 +2,13 @@ from __future__ import annotations -import asyncio.timeouts +import asyncio from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta import logging +from aiohttp import ClientResponseError from pymiele import MieleAction, MieleAPI, MieleDevice from homeassistant.config_entries import ConfigEntry @@ -66,7 +67,22 @@ async def _async_update_data(self) -> MieleCoordinatorData: self.devices = devices actions = {} for device_id in devices: - actions_json = await self.api.get_actions(device_id) + try: + actions_json = await self.api.get_actions(device_id) + except ClientResponseError as err: + _LOGGER.debug( + "Error fetching actions for device %s: Status: %s, Message: %s", + device_id, + err.status, + err.message, + ) + actions_json = {} + except TimeoutError: + _LOGGER.debug( + "Timeout fetching actions for device %s", + device_id, + ) + actions_json = {} actions[device_id] = MieleAction(actions_json) return MieleCoordinatorData(devices=devices, actions=actions) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 860336735f43fe..2075345e038e1b 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -669,7 +669,7 @@ "direction_value_template": "Direction value template" }, "data_description": { - "direction_command_topic": "The MQTT topic to publish commands to change the fan direction payload, either `forward` or `reverse`. Use the direction command template to customize the payload. [Learn more.]({url}#direction_command_topic)", + "direction_command_topic": "The MQTT topic to publish commands to change the fan direction. The payload will be either `forward` or `reverse` and can be customized using the direction command template. [Learn more.]({url}#direction_command_topic)", "direction_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the direction command topic. The template variable `value` will be either `forward` or `reverse`.", "direction_state_topic": "The MQTT topic subscribed to receive fan direction state. Accepted state payloads are `forward` or `reverse`. [Learn more.]({url}#direction_state_topic)", "direction_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract fan direction state value. The template should return either `forward` or `reverse`. When the template returns an empty string, the direction will be ignored." diff --git a/homeassistant/components/radio_browser/media_source.py b/homeassistant/components/radio_browser/media_source.py index e5bbf2db9f2aee..2cc243323a1043 100644 --- a/homeassistant/components/radio_browser/media_source.py +++ b/homeassistant/components/radio_browser/media_source.py @@ -134,7 +134,7 @@ async def _async_build_by_country( ) -> list[BrowseMediaSource]: """Handle browsing radio stations by country.""" category, _, country_code = (item.identifier or "").partition("/") - if country_code: + if category == "country" and country_code: stations = await radios.stations( filter_by=FilterBy.COUNTRY_CODE_EXACT, filter_term=country_code, @@ -185,7 +185,7 @@ async def _async_build_by_language( return [ BrowseMediaSource( domain=DOMAIN, - identifier=f"language/{language.code}", + identifier=f"language/{language.name.lower()}", media_class=MediaClass.DIRECTORY, media_content_type=MediaType.MUSIC, title=language.name, diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 5582ab488df5e2..d12236177b8103 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -67,6 +67,7 @@ get_http_port, get_rpc_scripts_event_types, get_ws_context, + remove_empty_sub_devices, remove_stale_blu_trv_devices, ) @@ -223,6 +224,7 @@ async def _async_setup_block_entry( await hass.config_entries.async_forward_entry_setups( entry, runtime_data.platforms ) + remove_empty_sub_devices(hass, entry) elif ( sleep_period is None or device_entry is None @@ -334,6 +336,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) hass, entry, ) + remove_empty_sub_devices(hass, entry) elif ( sleep_period is None or device_entry is None diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index c814c987621ba5..075040cb92996a 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -884,3 +884,27 @@ def remove_stale_blu_trv_devices( LOGGER.debug("Removing stale BLU TRV device %s", device.name) dev_reg.async_update_device(device.id, remove_config_entry_id=entry.entry_id) + + +@callback +def remove_empty_sub_devices(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove sub devices without entities.""" + dev_reg = dr.async_get(hass) + entity_reg = er.async_get(hass) + + devices = dev_reg.devices.get_devices_for_config_entry_id(entry.entry_id) + + for device in devices: + if not device.via_device_id: + # Device is not a sub-device, skip + continue + + if er.async_entries_for_device(entity_reg, device.id, True): + # Device has entities, skip + continue + + if any(identifier[0] == DOMAIN for identifier in device.identifiers): + LOGGER.debug("Removing empty sub-device %s", device.name) + dev_reg.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 536273df28f26f..7eaac3af8f9d5f 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -143,6 +143,7 @@ async def make_device_data( "Relay Switch 1PM", "Plug Mini (US)", "Plug Mini (JP)", + "Plug Mini (EU)", ]: coordinator = await coordinator_for_device( hass, entry, api, device, coordinators_by_id diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index b2d375573efebf..5b5274909b35d3 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -1,5 +1,9 @@ """Platform for sensor integration.""" +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + from switchbot_api import Device, SwitchBotAPI from homeassistant.components.sensor import ( @@ -14,6 +18,7 @@ PERCENTAGE, UnitOfElectricCurrent, UnitOfElectricPotential, + UnitOfEnergy, UnitOfPower, UnitOfTemperature, ) @@ -32,9 +37,26 @@ SENSOR_TYPE_POWER = "power" SENSOR_TYPE_VOLTAGE = "voltage" SENSOR_TYPE_CURRENT = "electricCurrent" +SENSOR_TYPE_USED_ELECTRICITY = "usedElectricity" SENSOR_TYPE_LIGHTLEVEL = "lightLevel" +@dataclass(frozen=True, kw_only=True) +class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): + """Plug Mini Eu UsedElectricity Sensor EntityDescription.""" + + value_fn: Callable[[Any], Any] = lambda value: value + + +USED_ELECTRICITY_DESCRIPTION = SwitchbotCloudSensorEntityDescription( + key=SENSOR_TYPE_USED_ELECTRICITY, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + value_fn=lambda data: (data.get(SENSOR_TYPE_USED_ELECTRICITY) or 0) / 60000, +) + TEMPERATURE_DESCRIPTION = SensorEntityDescription( key=SENSOR_TYPE_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, @@ -129,6 +151,12 @@ VOLTAGE_DESCRIPTION, CURRENT_DESCRIPTION_IN_MA, ), + "Plug Mini (EU)": ( + POWER_DESCRIPTION, + VOLTAGE_DESCRIPTION, + CURRENT_DESCRIPTION_IN_MA, + USED_ELECTRICITY_DESCRIPTION, + ), "Hub 2": ( TEMPERATURE_DESCRIPTION, HUMIDITY_DESCRIPTION, @@ -198,4 +226,15 @@ def _set_attributes(self) -> None: """Set attributes from coordinator data.""" if not self.coordinator.data: return - self._attr_native_value = self.coordinator.data.get(self.entity_description.key) + + if isinstance( + self.entity_description, + SwitchbotCloudSensorEntityDescription, + ): + self._attr_native_value = self.entity_description.value_fn( + self.coordinator.data + ) + else: + self._attr_native_value = self.coordinator.data.get( + self.entity_description.key + ) diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py index ebe20620d3e9b2..df21ae12adc3b8 100644 --- a/homeassistant/components/switchbot_cloud/switch.py +++ b/homeassistant/components/switchbot_cloud/switch.py @@ -83,13 +83,10 @@ def _async_make_entity( """Make a SwitchBotCloudSwitch or SwitchBotCloudRemoteSwitch.""" if isinstance(device, Remote): return SwitchBotCloudRemoteSwitch(api, device, coordinator) + if device.device_type in ["Relay Switch 1PM", "Relay Switch 1", "Plug Mini (EU)"]: + return SwitchBotCloudRelaySwitchSwitch(api, device, coordinator) if "Plug" in device.device_type: return SwitchBotCloudPlugSwitch(api, device, coordinator) - if device.device_type in [ - "Relay Switch 1PM", - "Relay Switch 1", - ]: - return SwitchBotCloudRelaySwitchSwitch(api, device, coordinator) if "Bot" in device.device_type: return SwitchBotCloudSwitch(api, device, coordinator) raise NotImplementedError(f"Unsupported device type: {device.device_type}") diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 0e336632b2ebf0..8b917d5d8bd35c 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.79"] + "requirements": ["holidays==0.80"] } diff --git a/homeassistant/const.py b/homeassistant/const.py index 913ef5e177f89a..3c9de2af87cc53 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -6,6 +6,7 @@ from functools import partial from typing import TYPE_CHECKING, Final +from .generated.entity_platforms import EntityPlatforms from .helpers.deprecation import ( DeprecatedConstant, DeprecatedConstantEnum, @@ -36,54 +37,8 @@ # Format for platform files PLATFORM_FORMAT: Final = "{platform}.{domain}" - -class Platform(StrEnum): - """Available entity platforms.""" - - AI_TASK = "ai_task" - AIR_QUALITY = "air_quality" - ALARM_CONTROL_PANEL = "alarm_control_panel" - ASSIST_SATELLITE = "assist_satellite" - BINARY_SENSOR = "binary_sensor" - BUTTON = "button" - CALENDAR = "calendar" - CAMERA = "camera" - CLIMATE = "climate" - CONVERSATION = "conversation" - COVER = "cover" - DATE = "date" - DATETIME = "datetime" - DEVICE_TRACKER = "device_tracker" - EVENT = "event" - FAN = "fan" - GEO_LOCATION = "geo_location" - HUMIDIFIER = "humidifier" - IMAGE = "image" - IMAGE_PROCESSING = "image_processing" - LAWN_MOWER = "lawn_mower" - LIGHT = "light" - LOCK = "lock" - MEDIA_PLAYER = "media_player" - NOTIFY = "notify" - NUMBER = "number" - REMOTE = "remote" - SCENE = "scene" - SELECT = "select" - SENSOR = "sensor" - SIREN = "siren" - STT = "stt" - SWITCH = "switch" - TEXT = "text" - TIME = "time" - TODO = "todo" - TTS = "tts" - UPDATE = "update" - VACUUM = "vacuum" - VALVE = "valve" - WAKE_WORD = "wake_word" - WATER_HEATER = "water_heater" - WEATHER = "weather" - +# Type alias to avoid 1000 MyPy errors +Platform = EntityPlatforms BASE_PLATFORMS: Final = {platform.value for platform in Platform} diff --git a/homeassistant/generated/entity_platforms.py b/homeassistant/generated/entity_platforms.py new file mode 100644 index 00000000000000..7010ffc9be73c1 --- /dev/null +++ b/homeassistant/generated/entity_platforms.py @@ -0,0 +1,54 @@ +"""Automatically generated file. + +To update, run python3 -m script.hassfest +""" + +from enum import StrEnum + + +class EntityPlatforms(StrEnum): + """Available entity platforms.""" + + AI_TASK = "ai_task" + AIR_QUALITY = "air_quality" + ALARM_CONTROL_PANEL = "alarm_control_panel" + ASSIST_SATELLITE = "assist_satellite" + BINARY_SENSOR = "binary_sensor" + BUTTON = "button" + CALENDAR = "calendar" + CAMERA = "camera" + CLIMATE = "climate" + CONVERSATION = "conversation" + COVER = "cover" + DATE = "date" + DATETIME = "datetime" + DEVICE_TRACKER = "device_tracker" + EVENT = "event" + FAN = "fan" + GEO_LOCATION = "geo_location" + HUMIDIFIER = "humidifier" + IMAGE = "image" + IMAGE_PROCESSING = "image_processing" + LAWN_MOWER = "lawn_mower" + LIGHT = "light" + LOCK = "lock" + MEDIA_PLAYER = "media_player" + NOTIFY = "notify" + NUMBER = "number" + REMOTE = "remote" + SCENE = "scene" + SELECT = "select" + SENSOR = "sensor" + SIREN = "siren" + STT = "stt" + SWITCH = "switch" + TEXT = "text" + TIME = "time" + TODO = "todo" + TTS = "tts" + UPDATE = "update" + VACUUM = "vacuum" + VALVE = "valve" + WAKE_WORD = "wake_word" + WATER_HEATER = "water_heater" + WEATHER = "weather" diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 70bded4b599324..3b4bafeded7bca 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -1118,18 +1118,14 @@ async def execute_service(self, service_call: ServiceCall) -> None: def _validate_entity_service_schema( - schema: VolDictType | VolSchemaType | None, + schema: VolDictType | VolSchemaType | None, service: str ) -> VolSchemaType: """Validate that a schema is an entity service schema.""" if schema is None or isinstance(schema, dict): return cv.make_entity_service_schema(schema) if not cv.is_entity_service_schema(schema): - from .frame import ReportBehavior, report_usage # noqa: PLC0415 - - report_usage( - "registers an entity service with a non entity service schema", - core_behavior=ReportBehavior.LOG, - breaks_in_ha_version="2025.9", + raise HomeAssistantError( + f"The {service} service registers an entity service with a non entity service schema" ) return schema @@ -1153,7 +1149,7 @@ def async_register_entity_service( EntityPlatform.async_register_entity_service and should not be called directly by integrations. """ - schema = _validate_entity_service_schema(schema) + schema = _validate_entity_service_schema(schema, f"{domain}.{name}") service_func: str | HassJob[..., Any] service_func = func if isinstance(func, str) else HassJob(func) @@ -1189,7 +1185,7 @@ def async_register_platform_entity_service( """Help registering a platform entity service.""" from .entity_platform import DATA_DOMAIN_PLATFORM_ENTITIES # noqa: PLC0415 - schema = _validate_entity_service_schema(schema) + schema = _validate_entity_service_schema(schema, f"{service_domain}.{service_name}") service_func: str | HassJob[..., Any] service_func = func if isinstance(func, str) else HassJob(func) diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 5502163472def6..493de266080f58 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -412,6 +412,7 @@ class PowerConverter(BaseUnitConverter): UnitOfPower.MEGA_WATT: 1 / 1e6, UnitOfPower.GIGA_WATT: 1 / 1e9, UnitOfPower.TERA_WATT: 1 / 1e12, + UnitOfPower.BTU_PER_HOUR: 1 / 0.29307107, } VALID_UNITS = { UnitOfPower.MILLIWATT, @@ -420,6 +421,7 @@ class PowerConverter(BaseUnitConverter): UnitOfPower.MEGA_WATT, UnitOfPower.GIGA_WATT, UnitOfPower.TERA_WATT, + UnitOfPower.BTU_PER_HOUR, } diff --git a/requirements_all.txt b/requirements_all.txt index 5494ed4d89b09e..e8bc69e54bfd74 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==40.1.0 +aioesphomeapi==40.2.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -271,7 +271,7 @@ aiohasupervisor==0.3.2 aiohomeconnect==0.19.0 # homeassistant.components.homekit_controller -aiohomekit==3.2.16 +aiohomekit==3.2.17 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 @@ -1178,7 +1178,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.79 +holidays==0.80 # homeassistant.components.frontend home-assistant-frontend==20250903.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a1fb4afe54039b..42467e066dc552 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==40.1.0 +aioesphomeapi==40.2.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -256,7 +256,7 @@ aiohasupervisor==0.3.2 aiohomeconnect==0.19.0 # homeassistant.components.homekit_controller -aiohomekit==3.2.16 +aiohomekit==3.2.17 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 @@ -1027,7 +1027,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.79 +holidays==0.80 # homeassistant.components.frontend home-assistant-frontend==20250903.5 diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index dfa99c6bc75146..43a6cc7678b832 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -19,6 +19,7 @@ dhcp, docker, icons, + integration_info, json, manifest, metadata, @@ -44,6 +45,7 @@ dependencies, dhcp, icons, + integration_info, json, manifest, mqtt, diff --git a/script/hassfest/integration_info.py b/script/hassfest/integration_info.py new file mode 100644 index 00000000000000..8747e256be7e84 --- /dev/null +++ b/script/hassfest/integration_info.py @@ -0,0 +1,42 @@ +"""Write integration constants.""" + +from __future__ import annotations + +from .model import Config, Integration +from .serializer import format_python + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Validate integrations file.""" + + if config.specific_integrations: + return + + int_type = "entity" + + domains = [ + integration.domain + for integration in integrations.values() + if integration.manifest.get("integration_type") == int_type + # Tag is type "entity" but has no entity platform + and integration.domain != "tag" + ] + + code = [ + "from enum import StrEnum", + "class EntityPlatforms(StrEnum):", + f' """Available {int_type} platforms."""', + ] + code.extend([f' {domain.upper()} = "{domain}"' for domain in sorted(domains)]) + + config.cache[f"integrations_{int_type}"] = format_python( + "\n".join(code), generator="script.hassfest" + ) + + +def generate(integrations: dict[str, Integration], config: Config) -> None: + """Generate integration file.""" + int_type = "entity" + filename = "entity_platforms" + platform_path = config.root / f"homeassistant/generated/{filename}.py" + platform_path.write_text(config.cache[f"integrations_{int_type}"]) diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py index af9a2cf62f1a8d..f7d20687c926f6 100644 --- a/tests/components/auth/test_login_flow.py +++ b/tests/components/auth/test_login_flow.py @@ -7,6 +7,7 @@ import pytest from homeassistant.core import HomeAssistant +from homeassistant.core_config import async_process_ha_core_config from . import BASE_CONFIG, async_setup_auth @@ -371,19 +372,54 @@ async def test_login_exist_user_ip_changes( assert response == {"message": "IP address changed"} +@pytest.mark.usefixtures("current_request_with_host") # Has example.com host +@pytest.mark.parametrize( + ("config", "expected_url_prefix"), + [ + ( + { + "internal_url": "http://192.168.1.100:8123", + # Current request matches external url + "external_url": "https://example.com", + }, + "https://example.com", + ), + ( + { + # Current request matches internal url + "internal_url": "https://example.com", + "external_url": "https://other.com", + }, + "https://example.com", + ), + ( + { + # Current request does not match either url + "internal_url": "https://other.com", + "external_url": "https://again.com", + }, + "", + ), + ], + ids=["external_url", "internal_url", "no_match"], +) async def test_well_known_auth_info( - hass: HomeAssistant, aiohttp_client: ClientSessionGenerator + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + config: dict[str, str], + expected_url_prefix: str, ) -> None: - """Test logging in and the ip address changes results in an rejection.""" + """Test the well-known OAuth authorization server endpoint with different URL configurations.""" + await async_process_ha_core_config(hass, config) client = await async_setup_auth(hass, aiohttp_client, setup_api=True) resp = await client.get( "/.well-known/oauth-authorization-server", ) assert resp.status == 200 assert await resp.json() == { - "authorization_endpoint": "/auth/authorize", - "token_endpoint": "/auth/token", - "revocation_endpoint": "/auth/revoke", + "authorization_endpoint": f"{expected_url_prefix}/auth/authorize", + "token_endpoint": f"{expected_url_prefix}/auth/token", + "revocation_endpoint": f"{expected_url_prefix}/auth/revoke", "response_types_supported": ["code"], "service_documentation": "https://developers.home-assistant.io/docs/auth_api", } diff --git a/tests/components/husqvarna_automower_ble/test_config_flow.py b/tests/components/husqvarna_automower_ble/test_config_flow.py index affa3715ab8208..967502f284d045 100644 --- a/tests/components/husqvarna_automower_ble/test_config_flow.py +++ b/tests/components/husqvarna_automower_ble/test_config_flow.py @@ -49,6 +49,22 @@ async def test_user_selection(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" + # mock connection error + with patch( + "homeassistant.components.husqvarna_automower_ble.config_flow.HusqvarnaAutomowerBleConfigFlow.probe_mower", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_PIN: "1234", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -488,9 +504,8 @@ async def test_exception_probe( result["flow_id"], user_input={CONF_PIN: "1234"}, ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} async def test_exception_connect( diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index bb6af6408b8c78..8f11888d1f2453 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -88,7 +88,7 @@ async def test_create_entity_error( assert res["success"], res assert not res["result"]["success"] assert res["result"]["errors"][0]["path"] == ["platform"] - assert res["result"]["error_base"].startswith("expected Platform or one of") + assert res["result"]["error_base"].startswith("expected EntityPlatforms or one of") # create entity with unsupported platform await client.send_json_auto_id( diff --git a/tests/components/lutron_caseta/__init__.py b/tests/components/lutron_caseta/__init__.py index 5f146cd988ac31..03b78b1e44ec13 100644 --- a/tests/components/lutron_caseta/__init__.py +++ b/tests/components/lutron_caseta/__init__.py @@ -100,6 +100,7 @@ def __init__(self, can_connect=True, timeout_on_connect=False) -> None: self.scenes = self.get_scenes() self.devices = self.load_devices() self.buttons = self.load_buttons() + self._subscribers: dict[str, list] = {} async def connect(self): """Connect the mock bridge.""" @@ -110,10 +111,23 @@ async def connect(self): def add_subscriber(self, device_id: str, callback_): """Mock a listener to be notified of state changes.""" + if device_id not in self._subscribers: + self._subscribers[device_id] = [] + self._subscribers[device_id].append(callback_) def add_button_subscriber(self, button_id: str, callback_): """Mock a listener for button presses.""" + def call_subscribers(self, device_id: str): + """Notify subscribers of a device state change.""" + if device_id in self._subscribers: + for callback in self._subscribers[device_id]: + callback() + + def get_device_by_id(self, device_id: str): + """Get a device by its ID.""" + return self.devices.get(device_id) + def is_connected(self): """Return whether the mock bridge is connected.""" return self.is_currently_connected @@ -309,6 +323,20 @@ def get_buttons(self): def tap_button(self, button_id: str): """Mock a button press and release message for the given button ID.""" + async def set_value(self, device_id: str, value: int) -> None: + """Mock setting a device value.""" + if device_id in self.devices: + self.devices[device_id]["current_state"] = value + + async def raise_cover(self, device_id: str) -> None: + """Mock raising a cover.""" + + async def lower_cover(self, device_id: str) -> None: + """Mock lowering a cover.""" + + async def stop_cover(self, device_id: str) -> None: + """Mock stopping a cover.""" + async def close(self): """Close the mock bridge connection.""" self.is_currently_connected = False diff --git a/tests/components/lutron_caseta/test_cover.py b/tests/components/lutron_caseta/test_cover.py index 5d45f185aeff54..43c7d986d1b483 100644 --- a/tests/components/lutron_caseta/test_cover.py +++ b/tests/components/lutron_caseta/test_cover.py @@ -1,18 +1,303 @@ """Tests for the Lutron Caseta integration.""" +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_STOP_COVER, +) +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import MockBridge, async_setup_integration +@pytest.fixture +async def mock_bridge_with_cover_mocks(hass: HomeAssistant) -> MockBridge: + """Set up mock bridge with all cover methods mocked for testing.""" + instance = MockBridge() + + def factory(*args: Any, **kwargs: Any) -> MockBridge: + """Return the mock bridge instance.""" + return instance + + # Patch all cover methods on the instance with AsyncMocks + instance.set_value = AsyncMock() + instance.raise_cover = AsyncMock() + instance.lower_cover = AsyncMock() + instance.stop_cover = AsyncMock() + + await async_setup_integration(hass, factory) + await hass.async_block_till_done() + + return instance + + async def test_cover_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: - """Test a light unique id.""" + """Test a cover unique ID.""" await async_setup_integration(hass, MockBridge) cover_entity_id = "cover.basement_bedroom_left_shade" # Assert that Caseta covers will have the bridge serial hash and the zone id as the uniqueID assert entity_registry.async_get(cover_entity_id).unique_id == "000004d2_802" + + +async def test_cover_open_close_using_set_value( + hass: HomeAssistant, mock_bridge_with_cover_mocks: MockBridge +) -> None: + """Test that open/close commands use set_value to avoid stuttering.""" + mock_instance = mock_bridge_with_cover_mocks + cover_entity_id = "cover.basement_bedroom_left_shade" + + # Test opening the cover + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Should use set_value(100) instead of raise_cover + mock_instance.set_value.assert_called_with("802", 100) + mock_instance.raise_cover.assert_not_called() + + mock_instance.set_value.reset_mock() + mock_instance.lower_cover.reset_mock() + + # Test closing the cover + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Should use set_value(0) instead of lower_cover + mock_instance.set_value.assert_called_with("802", 0) + mock_instance.lower_cover.assert_not_called() + + +async def test_cover_stop_with_direction_tracking( + hass: HomeAssistant, mock_bridge_with_cover_mocks: MockBridge +) -> None: + """Test that stop command sends appropriate directional command first.""" + mock_instance = mock_bridge_with_cover_mocks + cover_entity_id = "cover.basement_bedroom_left_shade" + + # Simulate shade moving up (opening) + mock_instance.devices["802"]["current_state"] = 30 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + mock_instance.devices["802"]["current_state"] = 60 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + # Now stop while opening + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Should send raise_cover before stop_cover when opening + mock_instance.raise_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + mock_instance.lower_cover.assert_not_called() + + mock_instance.raise_cover.reset_mock() + mock_instance.lower_cover.reset_mock() + mock_instance.stop_cover.reset_mock() + + # Simulate shade moving down (closing) + mock_instance.devices["802"]["current_state"] = 40 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + mock_instance.devices["802"]["current_state"] = 20 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + # Now stop while closing + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Should send lower_cover before stop_cover when closing + mock_instance.lower_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + mock_instance.raise_cover.assert_not_called() + + +async def test_cover_stop_at_endpoints( + hass: HomeAssistant, mock_bridge_with_cover_mocks: MockBridge +) -> None: + """Test stop command behavior when shade is at fully open or closed.""" + mock_instance = mock_bridge_with_cover_mocks + cover_entity_id = "cover.basement_bedroom_left_shade" + + # Test stop at fully open (100) - should infer it was opening + mock_instance.devices["802"]["current_state"] = 100 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # At fully open, should send raise_cover before stop + mock_instance.raise_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + + mock_instance.raise_cover.reset_mock() + mock_instance.lower_cover.reset_mock() + mock_instance.stop_cover.reset_mock() + + # Test stop at fully closed (0) - should infer it was closing + mock_instance.devices["802"]["current_state"] = 0 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # At fully closed, should send lower_cover before stop + mock_instance.lower_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + + +async def test_cover_position_heuristic_fallback( + hass: HomeAssistant, mock_bridge_with_cover_mocks: MockBridge +) -> None: + """Test stop command uses position heuristic when movement direction is unknown.""" + mock_instance = mock_bridge_with_cover_mocks + cover_entity_id = "cover.basement_bedroom_left_shade" + + # Test stop at position < 50 with no movement + # Update the device data directly in the bridge's devices dict + mock_instance.devices["802"]["current_state"] = 30 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Position < 50, should send lower_cover + mock_instance.lower_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + + mock_instance.raise_cover.reset_mock() + mock_instance.lower_cover.reset_mock() + mock_instance.stop_cover.reset_mock() + + # Test stop at position >= 50 with no movement + mock_instance.devices["802"]["current_state"] = 70 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Position >= 50, should send raise_cover + mock_instance.raise_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + + +async def test_cover_stopped_movement_detection( + hass: HomeAssistant, mock_bridge_with_cover_mocks: MockBridge +) -> None: + """Test that movement direction is set to STOPPED when position doesn't change.""" + mock_instance = mock_bridge_with_cover_mocks + cover_entity_id = "cover.basement_bedroom_left_shade" + + # Set initial position + mock_instance.devices["802"]["current_state"] = 50 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + # Send same position again - should detect as stopped + mock_instance.devices["802"]["current_state"] = 50 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + # Now stop command should use position heuristic (>= 50) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Position >= 50 with STOPPED direction, should send raise_cover + mock_instance.raise_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + + +async def test_cover_startup_with_shade_in_motion( + hass: HomeAssistant, mock_bridge_with_cover_mocks: MockBridge +) -> None: + """Test stop command when HA starts with shade already in motion.""" + mock_instance = mock_bridge_with_cover_mocks + cover_entity_id = "cover.basement_bedroom_left_shade" + + # Shade starts at position 50 (simulating HA startup with shade in motion) + # First stop without seeing movement should use position heuristic + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Should have used position heuristic since we haven't seen movement yet + # Initial position is 100 from MockBridge, so >= 50, should send raise_cover + mock_instance.raise_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + + mock_instance.raise_cover.reset_mock() + mock_instance.stop_cover.reset_mock() + + # Now simulate shade moving down (shade was actually in motion) + mock_instance.devices["802"]["current_state"] = 45 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + # Now we've detected downward movement + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Should now correctly send lower_cover since we detected downward movement + mock_instance.lower_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") diff --git a/tests/components/miele/test_init.py b/tests/components/miele/test_init.py index cdf1a39b4213cf..0448096a1157ec 100644 --- a/tests/components/miele/test_init.py +++ b/tests/components/miele/test_init.py @@ -5,7 +5,7 @@ import time from unittest.mock import MagicMock -from aiohttp import ClientConnectionError +from aiohttp import ClientConnectionError, ClientResponseError from freezegun.api import FrozenDateTimeFactory from pymiele import OAUTH2_TOKEN import pytest @@ -210,3 +210,29 @@ async def test_setup_all_platforms( # Check a sample sensor for each new device assert hass.states.get("sensor.dishwasher").state == "in_use" assert hass.states.get("sensor.oven_temperature_2").state == "175.0" + + +@pytest.mark.parametrize( + "side_effect", + [ + ClientResponseError("test", "Test"), + TimeoutError, + ], + ids=[ + "ClientResponseError", + "TimeoutError", + ], +) +async def test_load_entry_with_action_error( + hass: HomeAssistant, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, +) -> None: + """Test load with error from actions endpoint.""" + mock_miele_client.get_actions.side_effect = side_effect + await setup_integration(hass, mock_config_entry) + entry = mock_config_entry + + assert entry.state is ConfigEntryState.LOADED + assert mock_miele_client.get_actions.call_count == 5 diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index df38a246a7a95c..50754d2244b3c3 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -4941,9 +4941,15 @@ def set_state(entity_id, state, **kwargs): POWER_SENSOR_ATTRIBUTES, "W", "kW", - "GW, MW, TW, W, kW, mW", + "BTU/h, GW, MW, TW, W, kW, mW", + ), + ( + METRIC_SYSTEM, + POWER_SENSOR_ATTRIBUTES, + "W", + "kW", + "BTU/h, GW, MW, TW, W, kW, mW", ), - (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "GW, MW, TW, W, kW, mW"), ( US_CUSTOMARY_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, @@ -5159,9 +5165,15 @@ async def test_validate_statistics_unit_ignore_device_class( POWER_SENSOR_ATTRIBUTES, "W", "kW", - "GW, MW, TW, W, kW, mW", + "BTU/h, GW, MW, TW, W, kW, mW", + ), + ( + METRIC_SYSTEM, + POWER_SENSOR_ATTRIBUTES, + "W", + "kW", + "BTU/h, GW, MW, TW, W, kW, mW", ), - (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "GW, MW, TW, W, kW, mW"), ( US_CUSTOMARY_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index b1c3d1487b4ab0..69a7e266dcab67 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -26,7 +26,6 @@ CONNECTION_NETWORK_MAC, DeviceEntry, DeviceRegistry, - format_mac, ) from tests.common import MockConfigEntry, async_fire_time_changed @@ -152,7 +151,7 @@ def register_device( """Register Shelly device.""" return device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - connections={(CONNECTION_NETWORK_MAC, format_mac(MOCK_MAC))}, + connections={(CONNECTION_NETWORK_MAC, MOCK_MAC)}, ) @@ -163,7 +162,7 @@ def register_sub_device( return device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, f"{MOCK_MAC}-{unique_id}")}, - via_device=(DOMAIN, format_mac(MOCK_MAC)), + via_device=(DOMAIN, MOCK_MAC), ) diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 703df09bb6156d..8457354351f617 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -41,7 +41,7 @@ from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component -from . import MOCK_MAC, init_integration, mutate_rpc_device_status +from . import MOCK_MAC, init_integration, mutate_rpc_device_status, register_sub_device async def test_custom_coap_port( @@ -653,3 +653,30 @@ async def test_blu_trv_stale_device_removal( assert hass.states.get(trv_201_entity_id) is None assert device_registry.async_get(trv_201_entry.device_id) is None + + +async def test_empty_device_removal( + hass: HomeAssistant, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, + mock_rpc_device: Mock, +) -> None: + """Test removal of empty devices due to device configuration changes.""" + config_entry = await init_integration(hass, 3) + + # create empty sub-device + sub_device_entry = register_sub_device( + device_registry, + config_entry, + "boolean:201-boolean", + ) + + # verify that the sub-device is created + assert device_registry.async_get(sub_device_entry.id) is not None + + # device config change triggers a reload + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # verify that the empty sub-device is removed + assert device_registry.async_get(sub_device_entry.id) is None diff --git a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr index 85b2fcc2dcf474..90939eb50e4ab2 100644 --- a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr +++ b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_meter[device_info0-0][sensor.meter_1_battery-entry] +# name: test_no_coordinator_data[Meter][sensor.meter_1_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -36,7 +36,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_meter[device_info0-0][sensor.meter_1_battery-state] +# name: test_no_coordinator_data[Meter][sensor.meter_1_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', @@ -52,7 +52,7 @@ 'state': 'unknown', }) # --- -# name: test_meter[device_info0-0][sensor.meter_1_humidity-entry] +# name: test_no_coordinator_data[Meter][sensor.meter_1_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -89,7 +89,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_meter[device_info0-0][sensor.meter_1_humidity-state] +# name: test_no_coordinator_data[Meter][sensor.meter_1_humidity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', @@ -105,7 +105,7 @@ 'state': 'unknown', }) # --- -# name: test_meter[device_info0-0][sensor.meter_1_temperature-entry] +# name: test_no_coordinator_data[Meter][sensor.meter_1_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -145,7 +145,7 @@ 'unit_of_measurement': , }) # --- -# name: test_meter[device_info0-0][sensor.meter_1_temperature-state] +# name: test_no_coordinator_data[Meter][sensor.meter_1_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -161,7 +161,7 @@ 'state': 'unknown', }) # --- -# name: test_meter[device_info1-1][sensor.meter_1_battery-entry] +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -176,113 +176,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.meter_1_battery', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Battery', - 'platform': 'switchbot_cloud', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'meter-id-1_battery', - 'unit_of_measurement': '%', - }) -# --- -# name: test_meter[device_info1-1][sensor.meter_1_battery-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'meter-1 Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.meter_1_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '100', - }) -# --- -# name: test_meter[device_info1-1][sensor.meter_1_humidity-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.meter_1_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Humidity', - 'platform': 'switchbot_cloud', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'meter-id-1_humidity', - 'unit_of_measurement': '%', - }) -# --- -# name: test_meter[device_info1-1][sensor.meter_1_humidity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'friendly_name': 'meter-1 Humidity', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.meter_1_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '32', - }) -# --- -# name: test_meter[device_info1-1][sensor.meter_1_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.meter_1_temperature', + 'entity_id': 'sensor.meter_1_current', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -292,201 +186,44 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 1, + 'suggested_display_precision': 0, }), }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'switchbot_cloud', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'meter-id-1_temperature', - 'unit_of_measurement': , - }) -# --- -# name: test_meter[device_info1-1][sensor.meter_1_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'meter-1 Temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.meter_1_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '21.8', - }) -# --- -# name: test_meter[device_info2-2][sensor.contact_sensor_name_battery-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.contact_sensor_name_battery', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery', + 'original_name': 'Current', 'platform': 'switchbot_cloud', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'contact-sensor-id_battery', - 'unit_of_measurement': '%', - }) -# --- -# name: test_meter[device_info2-2][sensor.contact_sensor_name_battery-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'contact-sensor-name Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.contact_sensor_name_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '60', - }) -# --- -# name: test_meter[device_info3-3][sensor.hub_3_name_humidity-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.hub_3_name_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Humidity', - 'platform': 'switchbot_cloud', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'hub3-id_humidity', - 'unit_of_measurement': '%', - }) -# --- -# name: test_meter[device_info3-3][sensor.hub_3_name_humidity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'friendly_name': 'Hub-3-name Humidity', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.hub_3_name_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '55', + 'unique_id': 'meter-id-1_electricCurrent', + 'unit_of_measurement': , }) # --- -# name: test_meter[device_info3-3][sensor.hub_3_name_light_level-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.hub_3_name_light_level', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Light level', - 'platform': 'switchbot_cloud', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'light_level', - 'unique_id': 'hub3-id_lightLevel', - 'unit_of_measurement': None, - }) -# --- -# name: test_meter[device_info3-3][sensor.hub_3_name_light_level-state] +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Hub-3-name Light level', + 'device_class': 'current', + 'friendly_name': 'meter-1 Current', 'state_class': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.hub_3_name_light_level', + 'entity_id': 'sensor.meter_1_current', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '10', + 'state': 'unknown', }) # --- -# name: test_meter[device_info3-3][sensor.hub_3_name_temperature-entry] +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -495,7 +232,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.hub_3_name_temperature', + 'entity_id': 'sensor.meter_1_energy', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -505,38 +242,38 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 1, + 'suggested_display_precision': 2, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Temperature', + 'original_name': 'Energy', 'platform': 'switchbot_cloud', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'hub3-id_temperature', - 'unit_of_measurement': , + 'unique_id': 'meter-id-1_usedElectricity', + 'unit_of_measurement': , }) # --- -# name: test_meter[device_info3-3][sensor.hub_3_name_temperature-state] +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'Hub-3-name Temperature', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'energy', + 'friendly_name': 'meter-1 Energy', + 'state_class': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.hub_3_name_temperature', + 'entity_id': 'sensor.meter_1_energy', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '26.5', + 'state': 'unknown', }) # --- -# name: test_meter[device_info4-4][sensor.motion_sensor_name_battery-entry] +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -551,7 +288,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.motion_sensor_name_battery', + 'entity_id': 'sensor.meter_1_power', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -560,36 +297,39 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery', + 'original_name': 'Power', 'platform': 'switchbot_cloud', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'motion-sensor-id_battery', - 'unit_of_measurement': '%', + 'unique_id': 'meter-id-1_power', + 'unit_of_measurement': , }) # --- -# name: test_meter[device_info4-4][sensor.motion_sensor_name_battery-state] +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'motion-sensor-name Battery', + 'device_class': 'power', + 'friendly_name': 'meter-1 Power', 'state_class': , - 'unit_of_measurement': '%', + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.motion_sensor_name_battery', + 'entity_id': 'sensor.meter_1_power', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '20', + 'state': 'unknown', }) # --- -# name: test_meter[device_info5-5][sensor.water_detector_name_battery-entry] +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -604,7 +344,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.water_detector_name_battery', + 'entity_id': 'sensor.meter_1_voltage', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -613,32 +353,35 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery', + 'original_name': 'Voltage', 'platform': 'switchbot_cloud', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'water-detector-id_battery', - 'unit_of_measurement': '%', + 'unique_id': 'meter-id-1_voltage', + 'unit_of_measurement': , }) # --- -# name: test_meter[device_info5-5][sensor.water_detector_name_battery-state] +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'water-detector-name Battery', + 'device_class': 'voltage', + 'friendly_name': 'meter-1 Voltage', 'state_class': , - 'unit_of_measurement': '%', + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.water_detector_name_battery', + 'entity_id': 'sensor.meter_1_voltage', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '90', + 'state': 'unknown', }) # --- diff --git a/tests/components/switchbot_cloud/test_sensor.py b/tests/components/switchbot_cloud/test_sensor.py index 07a7521686bd0c..c132c5d8ca47a7 100644 --- a/tests/components/switchbot_cloud/test_sensor.py +++ b/tests/components/switchbot_cloud/test_sensor.py @@ -6,7 +6,7 @@ from switchbot_api import Device from syrupy.assertion import SnapshotAssertion -from homeassistant.components.switchbot_cloud.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -20,7 +20,7 @@ configure_integration, ) -from tests.common import async_load_json_array_fixture, snapshot_platform +from tests.common import snapshot_platform @pytest.mark.parametrize( @@ -45,10 +45,65 @@ async def test_meter( ) -> None: """Test all sensors.""" - mock_list_devices.return_value = [device_info] - json_data = await async_load_json_array_fixture(hass, "status.json", DOMAIN) - mock_get_status.return_value = json_data[index] +async def test_plug_mini_eu( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_list_devices, + mock_get_status, +) -> None: + """Test plug_mini_eu Used Electricity.""" + + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="Plug-id-1", + deviceName="Plug-1", + deviceType="Plug Mini (EU)", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + { + "usedElectricity": 3255, + "deviceId": "94A99054855E", + "deviceType": "Plug Mini (EU)", + }, + ] + + with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.SENSOR]): + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + "device_model", + [ + "Meter", + "Plug Mini (EU)", + ], +) +async def test_no_coordinator_data( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_list_devices, + mock_get_status, + device_model, +) -> None: + """Test meter sensors are unknown without coordinator data.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="meter-id-1", + deviceName="meter-1", + deviceType=device_model, + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = None with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.SENSOR]): entry = await configure_integration(hass) diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 20c243d070158e..c81c4dcd5cf392 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -560,11 +560,10 @@ def appender(**kwargs): async def test_register_entity_service_non_entity_service_schema( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, ) -> None: """Test attempting to register a service with a non entity service schema.""" component = EntityComponent(_LOGGER, DOMAIN, hass) - expected_message = "registers an entity service with a non entity service schema" for idx, schema in enumerate( ( @@ -573,9 +572,9 @@ async def test_register_entity_service_non_entity_service_schema( vol.Any(vol.Schema({"some": str})), ) ): - component.async_register_entity_service(f"hello_{idx}", schema, Mock()) - assert expected_message in caplog.text - caplog.clear() + expected_message = f"The test_domain.hello_{idx} service registers an entity service with a non entity service schema" + with pytest.raises(HomeAssistantError, match=expected_message): + component.async_register_entity_service(f"hello_{idx}", schema, Mock()) for idx, schema in enumerate( ( @@ -585,7 +584,6 @@ async def test_register_entity_service_non_entity_service_schema( ) ): component.async_register_entity_service(f"test_service_{idx}", schema, Mock()) - assert expected_message not in caplog.text async def test_register_entity_service_response_data(hass: HomeAssistant) -> None: diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 53331b676feb94..e973de0d2b4600 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -1878,13 +1878,12 @@ def handle_service(entity, *_): async def test_register_entity_service_non_entity_service_schema( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, ) -> None: """Test attempting to register a service with a non entity service schema.""" entity_platform = MockEntityPlatform( hass, domain="mock_integration", platform_name="mock_platform", platform=None ) - expected_message = "registers an entity service with a non entity service schema" for idx, schema in enumerate( ( @@ -1893,9 +1892,11 @@ async def test_register_entity_service_non_entity_service_schema( vol.Any(vol.Schema({"some": str})), ) ): - entity_platform.async_register_entity_service(f"hello_{idx}", schema, Mock()) - assert expected_message in caplog.text - caplog.clear() + expected_message = f"The mock_platform.hello_{idx} service registers an entity service with a non entity service schema" + with pytest.raises(HomeAssistantError, match=expected_message): + entity_platform.async_register_entity_service( + f"hello_{idx}", schema, Mock() + ) for idx, schema in enumerate( ( @@ -1907,7 +1908,6 @@ async def test_register_entity_service_non_entity_service_schema( entity_platform.async_register_entity_service( f"test_service_{idx}", schema, Mock() ) - assert expected_message not in caplog.text @pytest.mark.parametrize("update_before_add", [True, False]) diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 36fde1847711eb..9628e6f9bf850e 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -1,5 +1,6 @@ """Test selectors.""" +from collections.abc import Callable, Iterable from enum import Enum from typing import Any @@ -42,7 +43,11 @@ def test_invalid_base_schema(schema) -> None: def _test_selector( - selector_type, schema, valid_selections, invalid_selections, converter=None + selector_type: str, + schema: dict, + valid_selections: Iterable[Any], + invalid_selections: Iterable[Any], + converter: Callable[[Any], Any] | None = None, ): """Help test a selector.""" diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 73f4afc1f6d7bd..da4cdec4a0a486 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -5,7 +5,6 @@ from copy import deepcopy import dataclasses import io -import logging from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -39,6 +38,7 @@ SupportsResponse, callback, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( area_registry as ar, config_validation as cv, @@ -2761,7 +2761,7 @@ def handle_service(entity, *_): async def test_register_platform_entity_service_non_entity_service_schema( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, ) -> None: """Test attempting to register a service with a non entity service schema.""" expected_message = "registers an entity service with a non entity service schema" @@ -2773,16 +2773,15 @@ async def test_register_platform_entity_service_non_entity_service_schema( vol.Any(vol.Schema({"some": str})), ) ): - service.async_register_platform_entity_service( - hass, - "mock_platform", - f"hello_{idx}", - entity_domain="mock_integration", - schema=schema, - func=Mock(), - ) - assert expected_message in caplog.text - caplog.clear() + with pytest.raises(HomeAssistantError, match=expected_message): + service.async_register_platform_entity_service( + hass, + "mock_platform", + f"hello_{idx}", + entity_domain="mock_integration", + schema=schema, + func=Mock(), + ) for idx, schema in enumerate( ( @@ -2799,5 +2798,3 @@ async def test_register_platform_entity_service_non_entity_service_schema( schema=schema, func=Mock(), ) - assert expected_message not in caplog.text - assert not any(x.levelno > logging.DEBUG for x in caplog.records) diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index c25a40f5fc0e9e..2938db4732ec2b 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -664,6 +664,7 @@ (10, UnitOfPower.TERA_WATT, 10e12, UnitOfPower.WATT), (10, UnitOfPower.WATT, 0.01, UnitOfPower.KILO_WATT), (10, UnitOfPower.MILLIWATT, 0.01, UnitOfPower.WATT), + (10, UnitOfPower.BTU_PER_HOUR, 2.9307107, UnitOfPower.WATT), ], PressureConverter: [ (1000, UnitOfPressure.HPA, 14.5037743897, UnitOfPressure.PSI),