diff --git a/CODEOWNERS b/CODEOWNERS index 6a78706c143c7..b3642514baf1a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -779,8 +779,8 @@ CLAUDE.md @home-assistant/core /tests/components/homematicip_cloud/ @hahn-th @lackas /homeassistant/components/homevolt/ @danielhiversen @liudger /tests/components/homevolt/ @danielhiversen @liudger -/homeassistant/components/homewizard/ @DCSBL -/tests/components/homewizard/ @DCSBL +/homeassistant/components/homewizard/ @DCSBL @lexpostma +/tests/components/homewizard/ @DCSBL @lexpostma /homeassistant/components/honeywell/ @mkmer /tests/components/honeywell/ @mkmer /homeassistant/components/honeywell_string_lights/ @balloob diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index 8255df921a85c..dba3a4d6e3144 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["dsmr_parser"], - "requirements": ["dsmr-parser==1.11.0"] + "requirements": ["dsmr-parser==1.11.1"] } diff --git a/homeassistant/components/energieleser/manifest.json b/homeassistant/components/energieleser/manifest.json index b87071078e1ae..160cffb2c9d31 100644 --- a/homeassistant/components/energieleser/manifest.json +++ b/homeassistant/components/energieleser/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["energieleser==0.1.4"], + "requirements": ["energieleser==0.1.5"], "zeroconf": [ { "type": "_stromleser._tcp.local." diff --git a/homeassistant/components/esphome/coordinator.py b/homeassistant/components/esphome/coordinator.py index dfe4741a0d6e2..ec4e2ad9cc43d 100644 --- a/homeassistant/components/esphome/coordinator.py +++ b/homeassistant/components/esphome/coordinator.py @@ -14,6 +14,7 @@ _LOGGER = logging.getLogger(__name__) MIN_VERSION_SUPPORTS_UPDATE = AwesomeVersion("2023.1.0") +MIN_VERSION_SUPPORTS_BUILD_QUEUE = AwesomeVersion("2026.6.0") REFRESH_INTERVAL = timedelta(minutes=5) @@ -34,6 +35,7 @@ def __init__(self, hass: HomeAssistant, addon_slug: str, url: str) -> None: self.url = url self.api = ESPHomeDashboardAPI(url, async_get_clientsession(hass)) self.supports_update: bool | None = None + self.supports_build_queue = False @override async def _async_update_data(self) -> dict[str, ConfiguredDevice]: @@ -41,13 +43,14 @@ async def _async_update_data(self) -> dict[str, ConfiguredDevice]: devices = await self.api.get_devices() configured_devices = devices["configured"] - if ( - self.supports_update is None - and configured_devices - and (current_version := configured_devices[0].get("current_version")) + if configured_devices and ( + current_version := configured_devices[0].get("current_version") ): - self.supports_update = ( - AwesomeVersion(current_version) > MIN_VERSION_SUPPORTS_UPDATE - ) + version = AwesomeVersion(current_version) + if self.supports_update is None: + self.supports_update = version > MIN_VERSION_SUPPORTS_UPDATE + # The dashboard has its own build queue since 2026.6.0 + # and can accept multiple compile requests at once + self.supports_build_queue = version >= MIN_VERSION_SUPPORTS_BUILD_QUEUE return {dev["name"]: dev for dev in configured_devices} diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index eff6c6ab4b731..2b6f49272c04f 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -233,21 +233,27 @@ async def async_install( # Ensure only one OTA per device at a time async with self._install_lock: - # Ensure only one compile at a time for ALL devices - async with self.hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()): - coordinator = self.coordinator - api = coordinator.api - device = coordinator.data.get(self._device_info.name) - assert device is not None - configuration = device["configuration"] - if not await api.compile(configuration): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="error_compiling", - translation_placeholders={ - "configuration": configuration, - }, - ) + coordinator = self.coordinator + api = coordinator.api + device = coordinator.data.get(self._device_info.name) + assert device is not None + configuration = device["configuration"] + if coordinator.supports_build_queue: + # The dashboard has its own build queue + # and can handle concurrent compile requests + compiled = await api.compile(configuration) + else: + # Ensure only one compile at a time for ALL devices + async with self.hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()): + compiled = await api.compile(configuration) + if not compiled: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="error_compiling", + translation_placeholders={ + "configuration": configuration, + }, + ) # If the device uses deep sleep, there's a small chance it goes # to sleep right after the dashboard connects but before the OTA diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 14996cd487e21..db81df79958a2 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260624.4"] + "requirements": ["home-assistant-frontend==20260624.5"] } diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index a1741d5d2319d..f9a56ea3db9d7 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -1,7 +1,7 @@ { "domain": "homewizard", "name": "HomeWizard", - "codeowners": ["@DCSBL"], + "codeowners": ["@DCSBL", "@lexpostma"], "config_flow": true, "dhcp": [ { diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index b67949b3c01d7..f40d2d5fc04a4 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -133,7 +133,7 @@ async def async_will_remove_from_hass(self) -> None: self._expiration_trigger() self._expiration_trigger = None self._expired = False - await MqttEntity.async_will_remove_from_hass(self) + await super().async_will_remove_from_hass() @staticmethod @override diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index f50790616a07f..65edaf1033176 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -557,6 +557,7 @@ async def async_will_remove_from_hass(self) -> None: self._attributes_sub_state = async_unsubscribe_topics( self.hass, self._attributes_sub_state ) + await super().async_will_remove_from_hass() @callback def _attributes_message_received(self, msg: ReceiveMessage) -> None: @@ -708,6 +709,7 @@ async def async_will_remove_from_hass(self) -> None: self._availability_sub_state = async_unsubscribe_topics( self.hass, self._availability_sub_state ) + await super().async_will_remove_from_hass() @property @override @@ -1253,6 +1255,7 @@ def add_to_platform_abort(self) -> None: async def async_will_remove_from_hass(self) -> None: """Stop listening to signal and cleanup discovery data.""" self._cleanup_discovery_on_remove() + await super().async_will_remove_from_hass() def _cleanup_discovery_on_remove(self) -> None: """Stop listening to signal and cleanup discovery data.""" @@ -1575,9 +1578,7 @@ async def async_will_remove_from_hass(self) -> None: self._sub_state = subscription.async_unsubscribe_topics( self.hass, self._sub_state ) - await MqttAttributesMixin.async_will_remove_from_hass(self) - await MqttAvailabilityMixin.async_will_remove_from_hass(self) - await MqttDiscoveryUpdateMixin.async_will_remove_from_hass(self) + await super().async_will_remove_from_hass() debug_info.remove_entity_data(self.hass, self.entity_id) async def async_publish_with_config( diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index f9958db742533..80766f89499b4 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -19,6 +19,7 @@ ColorMode, LightEntity, LightEntityFeature, + LightEntityStateAttribute, filter_supported_color_modes, ) from homeassistant.const import ( @@ -361,17 +362,21 @@ async def _subscribe_topics(self) -> None: last_state = await self.async_get_last_state() if self._optimistic and last_state: self._attr_is_on = last_state.state == STATE_ON - if last_state.attributes.get(ATTR_BRIGHTNESS): - self._attr_brightness = last_state.attributes.get(ATTR_BRIGHTNESS) - if last_state.attributes.get(ATTR_HS_COLOR): - self._attr_hs_color = last_state.attributes.get(ATTR_HS_COLOR) + if brightness := last_state.attributes.get( + LightEntityStateAttribute.BRIGHTNESS + ): + self._attr_brightness = brightness + if hs_color := last_state.attributes.get( + LightEntityStateAttribute.HS_COLOR + ): + self._attr_hs_color = hs_color self._update_color_mode() - if last_state.attributes.get(ATTR_COLOR_TEMP_KELVIN): - self._attr_color_temp_kelvin = last_state.attributes.get( - ATTR_COLOR_TEMP_KELVIN - ) - if last_state.attributes.get(ATTR_EFFECT): - self._attr_effect = last_state.attributes.get(ATTR_EFFECT) + if color_temp_kelvin := last_state.attributes.get( + LightEntityStateAttribute.COLOR_TEMP_KELVIN + ): + self._attr_color_temp_kelvin = color_temp_kelvin + if effect := last_state.attributes.get(LightEntityStateAttribute.EFFECT): + self._attr_effect = effect @override async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 8c6879fa534ff..bab9483b37674 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -246,7 +246,7 @@ async def async_will_remove_from_hass(self) -> None: self._expiration_trigger() self._expiration_trigger = None self._expired = False - await MqttEntity.async_will_remove_from_hass(self) + await super().async_will_remove_from_hass() @staticmethod @override diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index 24feb44320b53..f06a06ffeeb24 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -4,6 +4,8 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import ( _LOGGER, @@ -12,11 +14,14 @@ CONF_FILTER_CORONA, CONF_FILTERS, CONF_HEADLINE_FILTER, + DOMAIN, NO_MATCH_REGEX, ) from .coordinator import NinaConfigEntry, NINADataUpdateCoordinator +from .services import async_setup_services PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool: @@ -32,6 +37,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool return True +async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: + """Set up services.""" + async_setup_services(hass) + return True + + async def async_unload_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index d7e4a4711dd00..d5e53cbcd359e 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -23,6 +23,17 @@ ATTR_WEB, CONF_MESSAGE_SLOTS, CONF_REGIONS, + SERVICE_DATA_AFFECTED_AREAS, + SERVICE_DATA_DESCRIPTION, + SERVICE_DATA_EXPIRES, + SERVICE_DATA_HEADLINE, + SERVICE_DATA_ID, + SERVICE_DATA_RECOMMENDED_ACTIONS, + SERVICE_DATA_SENDER, + SERVICE_DATA_SENT, + SERVICE_DATA_SEVERITY, + SERVICE_DATA_START, + SERVICE_DATA_WEB, ) from .coordinator import NinaConfigEntry, NINADataUpdateCoordinator from .entity import NinaEntity @@ -105,3 +116,24 @@ def extra_state_attributes(self) -> dict[str, Any]: if data.expires else "", # Deprecated, remove in 2026.11 } + + def get_details(self) -> dict[str, str] | None: + """Return the details of the warning.""" + if not self.is_on: + return None + + data = self._get_warning_data() + + return { + SERVICE_DATA_HEADLINE: data.headline, + SERVICE_DATA_DESCRIPTION: data.description, + SERVICE_DATA_SENDER: data.sender, + SERVICE_DATA_SEVERITY: data.severity or "Unknown", + SERVICE_DATA_RECOMMENDED_ACTIONS: data.recommended_actions, + SERVICE_DATA_AFFECTED_AREAS: data.affected_areas, + SERVICE_DATA_WEB: data.more_info_url, + SERVICE_DATA_ID: data.id, + SERVICE_DATA_SENT: data.sent.isoformat(), + SERVICE_DATA_START: data.start.isoformat() if data.start else "", + SERVICE_DATA_EXPIRES: data.expires.isoformat() if data.expires else "", + } diff --git a/homeassistant/components/nina/const.py b/homeassistant/components/nina/const.py index 9af64322eacf1..56e69dc4fc1f8 100644 --- a/homeassistant/components/nina/const.py +++ b/homeassistant/components/nina/const.py @@ -15,6 +15,20 @@ SEVERITY_VALUES: list[str] = ["extreme", "severe", "moderate", "minor", "unknown"] +SERVICE_GET_DETAILS: str = "get_details" + +SERVICE_DATA_HEADLINE: str = "headline" +SERVICE_DATA_DESCRIPTION: str = "description" +SERVICE_DATA_SENDER: str = "sender" +SERVICE_DATA_SEVERITY: str = "severity" +SERVICE_DATA_RECOMMENDED_ACTIONS: str = "recommended_actions" +SERVICE_DATA_AFFECTED_AREAS: str = "affected_areas" +SERVICE_DATA_WEB: str = "web" +SERVICE_DATA_ID: str = "id" +SERVICE_DATA_SENT: str = "sent" +SERVICE_DATA_START: str = "start" +SERVICE_DATA_EXPIRES: str = "expires" + CONF_REGIONS: str = "regions" CONF_MESSAGE_SLOTS: str = "slots" CONF_FILTERS: str = "filters" diff --git a/homeassistant/components/nina/icons.json b/homeassistant/components/nina/icons.json new file mode 100644 index 0000000000000..f0b4ac1a91152 --- /dev/null +++ b/homeassistant/components/nina/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "affected_areas": { + "default": "mdi:map-marker-radius" + }, + "headline": { + "default": "mdi:text-short" + }, + "more_info_url": { + "default": "mdi:web" + }, + "sender": { + "default": "mdi:account-tie-voice" + }, + "severity": { + "default": "mdi:alert" + } + } + }, + "services": { + "get_details": { "service": "mdi:download" } + } +} diff --git a/homeassistant/components/nina/quality_scale.yaml b/homeassistant/components/nina/quality_scale.yaml index 9f0f051f65497..39e827890a005 100644 --- a/homeassistant/components/nina/quality_scale.yaml +++ b/homeassistant/components/nina/quality_scale.yaml @@ -1,19 +1,13 @@ rules: # Bronze - action-setup: - status: exempt - comment: | - This integration does not provide additional actions. + action-setup: done appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: exempt - comment: | - This integration does not provide additional actions. + docs-actions: done docs-conditions: status: exempt comment: This integration does not have any conditions. @@ -35,10 +29,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: | - This integration does not provide additional actions. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done @@ -78,7 +69,7 @@ rules: entity-disabled-by-default: done entity-translations: done exception-translations: todo - icon-translations: todo + icon-translations: done reconfiguration-flow: todo repair-issues: status: exempt diff --git a/homeassistant/components/nina/services.py b/homeassistant/components/nina/services.py new file mode 100644 index 0000000000000..b26e9394de250 --- /dev/null +++ b/homeassistant/components/nina/services.py @@ -0,0 +1,21 @@ +"""Services for NINA.""" + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.core import HomeAssistant, SupportsResponse, callback +from homeassistant.helpers import service + +from .const import DOMAIN, SERVICE_GET_DETAILS + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register services.""" + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_GET_DETAILS, + entity_domain=BINARY_SENSOR_DOMAIN, + schema=None, + func="get_details", + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/nina/services.yaml b/homeassistant/components/nina/services.yaml new file mode 100644 index 0000000000000..2c57ec07fc2a2 --- /dev/null +++ b/homeassistant/components/nina/services.yaml @@ -0,0 +1,5 @@ +get_details: + target: + entity: + domain: binary_sensor + integration: nina diff --git a/homeassistant/components/nina/strings.json b/homeassistant/components/nina/strings.json index 2e36e2fd61e0b..ad7caa06570ff 100644 --- a/homeassistant/components/nina/strings.json +++ b/homeassistant/components/nina/strings.json @@ -129,5 +129,11 @@ "title": "Options" } } + }, + "services": { + "get_details": { + "description": "Get the details of a warning.", + "name": "Get warning details" + } } } diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index 78a3b3a9636f6..19f79bff7a0f6 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -118,7 +118,7 @@ async def _async_validate_connection( str(err), ) errors["base"] = "unknown" - except TimeoutError, httpx.ConnectError: + except TimeoutError, ConnectionError: errors["base"] = "cannot_connect" except Exception: _LOGGER.exception("Unexpected exception") @@ -278,7 +278,7 @@ async def async_step_set_options( downloaded_models: set[str] = { model_info["model"] for model_info in response.get("models", []) } - except TimeoutError, httpx.ConnectError, httpx.HTTPError: + except TimeoutError, httpx.HTTPError, ConnectionError: _LOGGER.exception("Failed to get models from Ollama server") return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/portainer/button.py b/homeassistant/components/portainer/button.py index 8d4a2fd3ba5f6..b2a4dd8b7d73d 100644 --- a/homeassistant/components/portainer/button.py +++ b/homeassistant/components/portainer/button.py @@ -37,8 +37,18 @@ @dataclass(frozen=True, kw_only=True) -class PortainerButtonDescription(ButtonEntityDescription): - """Class to describe a Portainer button entity.""" +class PortainerEndpointButtonDescription(ButtonEntityDescription): + """Class to describe a Portainer endpoint button entity.""" + + press_action: Callable[ + [Portainer, int], + Coroutine[Any, Any, None | DockerContainer], + ] + + +@dataclass(frozen=True, kw_only=True) +class PortainerContainerButtonDescription(ButtonEntityDescription): + """Class to describe a Portainer container button entity.""" press_action: Callable[ [Portainer, int, str], @@ -46,30 +56,30 @@ class PortainerButtonDescription(ButtonEntityDescription): ] -ENDPOINT_BUTTONS: tuple[PortainerButtonDescription, ...] = ( - PortainerButtonDescription( +ENDPOINT_BUTTONS: tuple[PortainerEndpointButtonDescription, ...] = ( + PortainerEndpointButtonDescription( key="images_prune", translation_key="images_prune", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_action=( - lambda portainer, endpoint_id, _: portainer.images_prune( + lambda portainer, endpoint_id: portainer.images_prune( endpoint_id=endpoint_id, dangling=False, until=timedelta(days=0) ) ), ), - PortainerButtonDescription( + PortainerEndpointButtonDescription( key="volumes_prune", translation_key="volumes_prune", entity_category=EntityCategory.CONFIG, press_action=( - lambda portainer, endpoint_id, _: portainer.prune_volumes(endpoint_id) + lambda portainer, endpoint_id: portainer.prune_volumes(endpoint_id) ), ), ) -CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = ( - PortainerButtonDescription( +CONTAINER_BUTTONS: tuple[PortainerContainerButtonDescription, ...] = ( + PortainerContainerButtonDescription( key="restart", translation_key="restart_container", device_class=ButtonDeviceClass.RESTART, @@ -80,7 +90,7 @@ class PortainerButtonDescription(ButtonEntityDescription): ) ), ), - PortainerButtonDescription( + PortainerContainerButtonDescription( key="pause", translation_key="pause_container", entity_category=EntityCategory.CONFIG, @@ -90,7 +100,7 @@ class PortainerButtonDescription(ButtonEntityDescription): ) ), ), - PortainerButtonDescription( + PortainerContainerButtonDescription( key="resume", translation_key="resume_container", entity_category=EntityCategory.CONFIG, @@ -100,7 +110,7 @@ class PortainerButtonDescription(ButtonEntityDescription): ) ), ), - PortainerButtonDescription( + PortainerContainerButtonDescription( key="recreate", translation_key="recreate_container", entity_category=EntityCategory.CONFIG, @@ -113,7 +123,7 @@ class PortainerButtonDescription(ButtonEntityDescription): ) ), ), - PortainerButtonDescription( + PortainerContainerButtonDescription( key="kill", translation_key="kill_container", entity_category=EntityCategory.CONFIG, @@ -186,7 +196,6 @@ class PortainerBaseButton(ButtonEntity): Ensures the async_press logic isn't duplicated. """ - entity_description: PortainerButtonDescription coordinator: PortainerCoordinator @abstractmethod @@ -201,17 +210,17 @@ async def async_press(self) -> None: except PortainerConnectionError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_connect_no_details", + translation_key="cannot_connect", ) from err except PortainerAuthenticationError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="invalid_auth_no_details", + translation_key="invalid_auth", ) from err except PortainerTimeoutError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="timeout_connect_no_details", + translation_key="timeout_connect", ) from err await self.coordinator.async_request_refresh() @@ -220,20 +229,20 @@ async def async_press(self) -> None: class PortainerEndpointButton(PortainerEndpointEntity, PortainerBaseButton): """Defines a Portainer endpoint button.""" - entity_description: PortainerButtonDescription + entity_description: PortainerEndpointButtonDescription @override async def _async_press_call(self) -> None: """Call the endpoint button press action.""" await self.entity_description.press_action( - self.coordinator.portainer, self.device_id, "" + self.coordinator.portainer, self.device_id ) class PortainerContainerButton(PortainerContainerEntity, PortainerBaseButton): """Defines a Portainer button.""" - entity_description: PortainerButtonDescription + entity_description: PortainerContainerButtonDescription @override async def _async_press_call(self) -> None: diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index a876fa2a1135a..80d3d37e3273c 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -141,19 +141,16 @@ async def _async_setup(self) -> None: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", - translation_placeholders={"error": repr(err)}, ) from err except PortainerConnectionError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", - translation_placeholders={"error": repr(err)}, ) from err except PortainerTimeoutError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="timeout_connect", - translation_placeholders={"error": repr(err)}, ) from err @abstractmethod @@ -169,19 +166,16 @@ async def _async_update_data(self) -> _DataT: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", - translation_placeholders={"error": repr(err)}, ) from err except PortainerConnectionError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", - translation_placeholders={"error": repr(err)}, ) from err except PortainerTimeoutError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="timeout_connect", - translation_placeholders={"error": repr(err)}, ) from err @@ -221,13 +215,11 @@ async def update_data(self) -> dict[int, PortainerCoordinatorData]: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", - translation_placeholders={"error": repr(err)}, ) from err except PortainerConnectionError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", - translation_placeholders={"error": repr(err)}, ) from err mapped_endpoints: dict[int, PortainerCoordinatorData] = {} diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index f60fe1e30700d..9787cd141e7ce 100644 --- a/homeassistant/components/portainer/manifest.json +++ b/homeassistant/components/portainer/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/portainer", "integration_type": "service", "iot_class": "local_polling", + "loggers": ["pyportainer"], "quality_scale": "platinum", "requirements": ["pyportainer==1.0.38"] } diff --git a/homeassistant/components/portainer/services.py b/homeassistant/components/portainer/services.py index 6ed749d5abfff..bbd910bd8cd06 100644 --- a/homeassistant/components/portainer/services.py +++ b/homeassistant/components/portainer/services.py @@ -136,17 +136,17 @@ async def prune_images(call: ServiceCall) -> None: except PortainerAuthenticationError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="invalid_auth_no_details", + translation_key="invalid_auth", ) from err except PortainerConnectionError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_connect_no_details", + translation_key="cannot_connect", ) from err except PortainerTimeoutError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="timeout_connect_no_details", + translation_key="timeout_connect", ) from err @@ -170,17 +170,17 @@ async def recreate_container(call: ServiceCall) -> None: except PortainerAuthenticationError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="invalid_auth_no_details", + translation_key="invalid_auth", ) from err except PortainerConnectionError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_connect_no_details", + translation_key="cannot_connect", ) from err except PortainerTimeoutError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="timeout_connect_no_details", + translation_key="timeout_connect", ) from err await coordinator.async_request_refresh() diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index 1a2854ac3e87a..d32ebe40ce424 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -201,24 +201,15 @@ }, "exceptions": { "cannot_connect": { - "message": "An error occurred while trying to connect to the Portainer instance: {error}" - }, - "cannot_connect_no_details": { "message": "An error occurred while trying to connect to the Portainer instance." }, "invalid_auth": { - "message": "An error occurred while trying to authenticate: {error}" - }, - "invalid_auth_no_details": { "message": "An error occurred while trying to authenticate." }, "invalid_target": { "message": "Invalid device targeted." }, "timeout_connect": { - "message": "A timeout occurred while trying to connect to the Portainer instance: {error}" - }, - "timeout_connect_no_details": { "message": "A timeout occurred while trying to connect to the Portainer instance." } }, diff --git a/homeassistant/components/portainer/switch.py b/homeassistant/components/portainer/switch.py index e95274a0103c7..7c87e056d01cd 100644 --- a/homeassistant/components/portainer/switch.py +++ b/homeassistant/components/portainer/switch.py @@ -65,17 +65,17 @@ async def _perform_action( except PortainerAuthenticationError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="invalid_auth_no_details", + translation_key="invalid_auth", ) from err except PortainerConnectionError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_connect_no_details", + translation_key="cannot_connect", ) from err except PortainerTimeoutError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="timeout_connect_no_details", + translation_key="timeout_connect", ) from err else: await coordinator.async_request_refresh() diff --git a/homeassistant/components/portainer/update.py b/homeassistant/components/portainer/update.py index a40b43e8d61ac..da3d3f529e868 100644 --- a/homeassistant/components/portainer/update.py +++ b/homeassistant/components/portainer/update.py @@ -174,12 +174,12 @@ async def async_install( self.coordinator.config_entry.async_start_reauth(self.hass) raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="invalid_auth_no_details", + translation_key="invalid_auth", ) from ex except PortainerConnectionError as ex: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_connect_no_details", + translation_key="cannot_connect", ) from ex else: await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/proxmoxve/button.py b/homeassistant/components/proxmoxve/button.py index e18a9922685d0..5c5bdda0f1144 100644 --- a/homeassistant/components/proxmoxve/button.py +++ b/homeassistant/components/proxmoxve/button.py @@ -324,22 +324,22 @@ async def async_press(self) -> None: except AuthenticationError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_connect_no_details", + translation_key="cannot_connect", ) from err except SSLError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="invalid_auth_no_details", + translation_key="invalid_auth", ) from err except ConnectTimeout as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="timeout_connect_no_details", + translation_key="timeout_connect", ) from err except (ResourceException, requests.exceptions.ConnectionError) as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="api_error_no_details", + translation_key="api_error_details", ) from err diff --git a/homeassistant/components/proxmoxve/coordinator.py b/homeassistant/components/proxmoxve/coordinator.py index e9cfffc16f6bc..b701fa975a8b7 100644 --- a/homeassistant/components/proxmoxve/coordinator.py +++ b/homeassistant/components/proxmoxve/coordinator.py @@ -110,25 +110,21 @@ async def _async_setup(self) -> None: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", - translation_placeholders={"error": repr(err)}, ) from err except SSLError as err: raise ConfigEntryError( translation_domain=DOMAIN, translation_key="ssl_error", - translation_placeholders={"error": repr(err)}, ) from err except ConnectTimeout as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="timeout_connect", - translation_placeholders={"error": repr(err)}, ) from err except ProxmoxServerError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="api_error_details", - translation_placeholders={"error": repr(err)}, ) from err except ProxmoxPermissionsError as err: raise ConfigEntryAuthFailed( @@ -144,7 +140,6 @@ async def _async_setup(self) -> None: raise ConfigEntryError( translation_domain=DOMAIN, translation_key="cannot_connect", - translation_placeholders={"error": repr(err)}, ) from err @override @@ -157,19 +152,16 @@ async def _async_update_data(self) -> dict[str, ProxmoxNodeData]: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", - translation_placeholders={"error": repr(err)}, ) from err except SSLError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="ssl_error", - translation_placeholders={"error": repr(err)}, ) from err except ConnectTimeout as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="timeout_connect", - translation_placeholders={"error": repr(err)}, ) from err except ResourceException as err: raise UpdateFailed( @@ -180,7 +172,6 @@ async def _async_update_data(self) -> dict[str, ProxmoxNodeData]: raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", - translation_placeholders={"error": repr(err)}, ) from err data: dict[str, ProxmoxNodeData] = {} diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index 62316d3508e07..fd35574b8fc29 100644 --- a/homeassistant/components/proxmoxve/strings.json +++ b/homeassistant/components/proxmoxve/strings.json @@ -297,22 +297,13 @@ }, "exceptions": { "api_error_details": { - "message": "An error occurred while communicating with the Proxmox VE instance: {error}" - }, - "api_error_no_details": { "message": "An error occurred while communicating with the Proxmox VE instance." }, "cannot_connect": { - "message": "An error occurred while trying to connect to the Proxmox VE instance: {error}" - }, - "cannot_connect_no_details": { - "message": "Could not connect to the Proxmox VE instance." + "message": "An error occurred while trying to connect to the Proxmox VE instance." }, "invalid_auth": { - "message": "An error occurred while trying to authenticate: {error}" - }, - "invalid_auth_no_details": { - "message": "Authentication failed for the Proxmox VE instance." + "message": "An error occurred while trying to authenticate." }, "no_nodes_found": { "message": "No active nodes were found on the Proxmox VE server." @@ -333,12 +324,9 @@ "message": "Failed to retrieve Proxmox VE permissions. Please check your credentials and try again." }, "ssl_error": { - "message": "An SSL error occurred: {error}" + "message": "An SSL error occurred." }, "timeout_connect": { - "message": "A timeout occurred while trying to connect to the Proxmox VE instance: {error}" - }, - "timeout_connect_no_details": { "message": "A timeout occurred while trying to connect to the Proxmox VE instance." } }, diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 8872ad174536d..a4268319b3bc8 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -531,8 +531,8 @@ async def async_added_to_hass(self) -> None: self._attr_hvac_mode = ( HVACMode(state.state) if state.state in HVAC_MODES else None ) - self._attr_current_temperature = state.attributes.get("temperature") - self._attr_target_temperature = state.attributes.get("target_temperature") + self._attr_current_temperature = state.attributes.get("current_temperature") + self._attr_target_temperature = state.attributes.get("temperature") self.async_on_remove( self.vehicle.stream_vehicle.listen_InsideTemp( diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 18f5551f7fbd9..5e9076c213eca 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -44,7 +44,7 @@ "iot_class": "cloud_push", "loggers": ["tuya_sharing"], "requirements": [ - "tuya-device-handlers==0.0.24", + "tuya-device-handlers==0.0.25", "tuya-device-sharing-sdk==0.2.10" ] } diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py index 3adce293632b0..2125663e4bf01 100644 --- a/homeassistant/components/velbus/sensor.py +++ b/homeassistant/components/velbus/sensor.py @@ -40,6 +40,7 @@ class VelbusSensorEntityDescription(SensorEntityDescription): key="power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda channel: float(channel.get_counter_state()), unit_fn=lambda channel: channel.get_unit(), ), "temperature": VelbusSensorEntityDescription( @@ -58,7 +59,11 @@ class VelbusSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.ENERGY, icon="mdi:counter", state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda channel: float(channel.get_counter_state()), + value_fn=lambda channel: ( + float(channel.energy) + if hasattr(channel, "energy") and channel.energy is not None + else None + ), unit_fn=lambda channel: channel.get_counter_unit(), unique_id_suffix="-counter", ), diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 41fb2cc34ed31..dd52d40aae22b 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -8,6 +8,7 @@ from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareFloorHeating import FloorHeating from PyViCare.PyViCareHeatingDevice import ( HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent, ) @@ -1274,6 +1275,16 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM SUPPLY_TEMPERATURE_SENSOR, ) +FLOOR_HEATING_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( + ViCareSensorEntityDescription( + key="active_mode", + translation_key="active_mode", + device_class=SensorDeviceClass.ENUM, + options=["cooling", "heating", "standby"], + value_getter=lambda api: api.getActiveMode(), + ), +) + BURNER_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ViCareSensorEntityDescription( key="burner_starts", @@ -1509,6 +1520,18 @@ def _build_entities( for description in GLOBAL_SENSORS if is_supported(description.key, description.value_getter, device.api) ) + # add device-class-specific entities + if isinstance(device.api, FloorHeating): + entities.extend( + ViCareSensor( + description, + get_device_serial(device.api), + device.config, + device.api, + ) + for description in FLOOR_HEATING_SENSORS + if is_supported(description.key, description.value_getter, device.api) + ) # add component entities for component_list, entity_description_list in ( (get_circuits(device.api), CIRCUIT_SENSORS), diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 314dfd44e23fd..92e2608b0a8d7 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -161,6 +161,14 @@ } }, "sensor": { + "active_mode": { + "name": "Mode", + "state": { + "cooling": "Cooling", + "heating": "Heating", + "standby": "[%key:common::state::standby%]" + } + }, "boiler_supply_temperature": { "name": "Boiler supply temperature" }, diff --git a/homeassistant/components/wiim/manifest.json b/homeassistant/components/wiim/manifest.json index a93652c0e978e..83d724c308a44 100644 --- a/homeassistant/components/wiim/manifest.json +++ b/homeassistant/components/wiim/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["wiim.sdk", "async_upnp_client"], "quality_scale": "bronze", - "requirements": ["wiim==0.1.4"], + "requirements": ["wiim==0.1.5"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/homeassistant/components/yardian/__init__.py b/homeassistant/components/yardian/__init__.py index 96daa42561bb0..b6b7ffcbef0d2 100644 --- a/homeassistant/components/yardian/__init__.py +++ b/homeassistant/components/yardian/__init__.py @@ -10,6 +10,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/yardian/button.py b/homeassistant/components/yardian/button.py new file mode 100644 index 0000000000000..379fa2c17df22 --- /dev/null +++ b/homeassistant/components/yardian/button.py @@ -0,0 +1,44 @@ +"""Support for Yardian buttons.""" + +import asyncio +from typing import override + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import BUTTON_REFRESH_DELAY +from .coordinator import YardianConfigEntry, YardianUpdateCoordinator +from .entity import YardianEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: YardianConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Yardian button platform.""" + coordinator = entry.runtime_data + + async_add_entities([YardianStopButton(coordinator)]) + + +class YardianStopButton(YardianEntity, ButtonEntity): + """Representation of a Yardian Stop All Irrigation button.""" + + _attr_translation_key = "stop_irrigation" + + def __init__(self, coordinator: YardianUpdateCoordinator) -> None: + """Initialize the button.""" + super().__init__(coordinator) + self.client = coordinator.controller + + self._attr_unique_id = f"{coordinator.yid}_stop_all" + + @override + async def async_press(self) -> None: + """Handle the button press.""" + await self.client.stop_irrigation() + + await asyncio.sleep(BUTTON_REFRESH_DELAY) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/yardian/const.py b/homeassistant/components/yardian/const.py index 4b77d8d8b79b5..fcbedaed7ee98 100644 --- a/homeassistant/components/yardian/const.py +++ b/homeassistant/components/yardian/const.py @@ -6,3 +6,4 @@ DEFAULT_WATERING_DURATION = 6 SWITCH_REFRESH_DELAY = 2 +BUTTON_REFRESH_DELAY = 3 diff --git a/homeassistant/components/yardian/strings.json b/homeassistant/components/yardian/strings.json index 50db7dd91cf60..b9687238016bf 100644 --- a/homeassistant/components/yardian/strings.json +++ b/homeassistant/components/yardian/strings.json @@ -35,6 +35,11 @@ "name": "Enabled" } }, + "button": { + "stop_irrigation": { + "name": "Stop irrigation" + } + }, "sensor": { "active_zone_count": { "name": "Active zones", diff --git a/homeassistant/helpers/location.py b/homeassistant/helpers/location.py index 772cb2d572909..9d268b74f6d5b 100644 --- a/homeassistant/helpers/location.py +++ b/homeassistant/helpers/location.py @@ -76,13 +76,13 @@ def find_coordinates( # Check if entity_state is a zone zone_entity = hass.states.get(f"zone.{entity_state.state}") - if has_location(zone_entity): # type: ignore[arg-type] + if zone_entity and has_location(zone_entity): _LOGGER.debug( "%s is in %s, getting zone location", name, - zone_entity.entity_id, # type: ignore[union-attr] + zone_entity.entity_id, ) - return _get_location_from_attributes(zone_entity) # type: ignore[arg-type] + return _get_location_from_attributes(zone_entity) # Check if entity_state is a friendly name of a zone if (zone_coords := resolve_zone(hass, entity_state.state)) is not None: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 39f1937a231db..2308d37fb0dce 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==6.26.5 hass-nabucasa==2.2.0 hassil==3.8.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260624.4 +home-assistant-frontend==20260624.5 home-assistant-intents==2026.6.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py index f9e4b3d4ebec3..8cdb0231c569e 100644 --- a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py +++ b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py @@ -5,7 +5,7 @@ from typing import Final -FRONTEND_VERSION: Final[str] = "20260624.4" +FRONTEND_VERSION: Final[str] = "20260624.5" MDI_ICONS: Final[set[str]] = { "ab-testing", diff --git a/requirements_all.txt b/requirements_all.txt index c60e8a801aa4f..ed824d5ed04f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -856,7 +856,7 @@ dremel3dpy==2.1.1 dropmqttapi==1.0.3 # homeassistant.components.dsmr -dsmr-parser==1.11.0 +dsmr-parser==1.11.1 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.7 @@ -919,7 +919,7 @@ emoji==2.8.0 emulated-roku==0.3.0 # homeassistant.components.energieleser -energieleser==0.1.4 +energieleser==0.1.5 # homeassistant.components.huisbaasje energyflip-client==0.2.2 @@ -1269,7 +1269,7 @@ hole==0.9.2 holidays==0.100 # homeassistant.components.frontend -home-assistant-frontend==20260624.4 +home-assistant-frontend==20260624.5 # homeassistant.components.conversation home-assistant-intents==2026.6.24 @@ -3225,7 +3225,7 @@ ttls==1.8.3 ttn_client==1.3.0 # homeassistant.components.tuya -tuya-device-handlers==0.0.24 +tuya-device-handlers==0.0.25 # homeassistant.components.tuya tuya-device-sharing-sdk==0.2.10 @@ -3369,7 +3369,7 @@ whois==0.9.27 wiffi==1.1.2 # homeassistant.components.wiim -wiim==0.1.4 +wiim==0.1.5 # homeassistant.components.wirelesstag wirelesstagpy==0.8.1 diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 1bdb4b4ac6375..c29606fdff1c6 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -10,6 +10,7 @@ import pytest from homeassistant.components.esphome.dashboard import async_get_dashboard +from homeassistant.components.esphome.update import KEY_UPDATE_LOCK from homeassistant.components.homeassistant import ( DOMAIN as HOMEASSISTANT_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -811,6 +812,98 @@ async def delayed_compile(*args: Any, **kwargs: Any) -> None: await update_task +async def test_update_dashboard_with_build_queue_skips_global_lock( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_dashboard: dict[str, Any], +) -> None: + """Test the global compile lock is skipped when the dashboard has a build queue.""" + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2026.6.0", + "configuration": "test.yaml", + } + ] + await async_get_dashboard(hass).async_refresh() + await mock_esphome_device(mock_client=mock_client) + await hass.async_block_till_done() + + # Hold the global compile lock; the install must not need it + lock = hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()) + await lock.acquire() + with ( + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=True, + ) as mock_compile, + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=True, + ), + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, + blocking=True, + ) + lock.release() + + assert len(mock_compile.mock_calls) == 1 + assert mock_compile.mock_calls[0][1][0] == "test.yaml" + + +async def test_update_dashboard_without_build_queue_waits_for_global_lock( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_dashboard: dict[str, Any], +) -> None: + """Test the global compile lock still serializes installs on older dashboards.""" + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2026.5.0", + "configuration": "test.yaml", + } + ] + await async_get_dashboard(hass).async_refresh() + await mock_esphome_device(mock_client=mock_client) + await hass.async_block_till_done() + + lock = hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()) + await lock.acquire() + with ( + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=True, + ) as mock_compile, + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=True, + ), + ): + update_task = hass.async_create_task( + hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, + blocking=True, + ) + ) + for _ in range(5): + await asyncio.sleep(0) + # The compile must be blocked on the global lock + assert len(mock_compile.mock_calls) == 0 + lock.release() + await update_task + + assert len(mock_compile.mock_calls) == 1 + assert mock_compile.mock_calls[0][1][0] == "test.yaml" + + async def test_update_deep_sleep_already_online( hass: HomeAssistant, mock_client: APIClient, diff --git a/tests/components/nina/snapshots/test_service.ambr b/tests/components/nina/snapshots/test_service.ambr new file mode 100644 index 0000000000000..e5e91fd7b997d --- /dev/null +++ b/tests/components/nina/snapshots/test_service.ambr @@ -0,0 +1,18 @@ +# serializer version: 1 +# name: test_service_get_details + dict({ + 'binary_sensor.aach_warning_1': dict({ + 'affected_areas': 'Gemeinde Oberreichenbach, Gemeinde Neuweiler, Stadt Nagold, Stadt Neubulach, Gemeinde Schömberg, Gemeinde Simmersfeld, Gemeinde Simmozheim, Gemeinde Rohrdorf, Gemeinde Ostelsheim, Gemeinde Ebhausen, Gemeinde Egenhausen, Gemeinde Dobel, Stadt Bad Liebenzell, Stadt Solingen, Stadt Haiterbach, Stadt Bad Herrenalb, Gemeinde Höfen an der Enz, Gemeinde Gechingen, Gemeinde Enzklösterle, Gemeinde Gutach (Schwarzwaldbahn) und 3392 weitere.', + 'description': 'Es treten Sturmböen mit Geschwindigkeiten zwischen 70 km/h (20m/s, 38kn, Bft 8) und 85 km/h (24m/s, 47kn, Bft 9) aus westlicher Richtung auf. In Schauernähe sowie in exponierten Lagen muss mit schweren Sturmböen bis 90 km/h (25m/s, 48kn, Bft 10) gerechnet werden.', + 'expires': '3021-11-22T05:19:00+01:00', + 'headline': 'Ausfall Notruf 112', + 'id': 'mow.DE-NW-BN-SE030-20201014-30-000', + 'recommended_actions': 'ACHTUNG! Hinweis auf mögliche Gefahren: Es können zum Beispiel einzelne Äste herabstürzen. Achte besonders auf herabfallende Gegenstände.', + 'sender': 'Deutscher Wetterdienst', + 'sent': '2021-10-11T05:20:00+01:00', + 'severity': 'Minor', + 'start': '2021-11-01T05:20:00+01:00', + 'web': 'https://www.wettergefahren.de', + }), + }) +# --- diff --git a/tests/components/nina/test_service.py b/tests/components/nina/test_service.py new file mode 100644 index 0000000000000..a10b2efece13d --- /dev/null +++ b/tests/components/nina/test_service.py @@ -0,0 +1,67 @@ +"""Test the Nina services.""" + +from unittest.mock import AsyncMock + +from pynina import Warning +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.nina.const import DOMAIN, SERVICE_GET_DETAILS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from . import setup_platform, setup_single_platform + +from tests.common import MockConfigEntry + + +async def test_service_registration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_nina_class: AsyncMock +) -> None: + """Test the NINA services be registered.""" + await setup_single_platform(hass, mock_config_entry, None, mock_nina_class, []) + + services = hass.services.async_services_for_domain(DOMAIN) + + assert len(services) == 1 + assert SERVICE_GET_DETAILS in services + + +async def test_service_get_details( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nina_class: AsyncMock, + nina_warnings: list[Warning], + snapshot: SnapshotAssertion, +) -> None: + """Test the get details service.""" + await setup_platform(hass, mock_config_entry, mock_nina_class, nina_warnings) + + target_entity_id = "binary_sensor.aach_warning_1" + + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_DETAILS, + {ATTR_ENTITY_ID: target_entity_id}, + blocking=True, + return_response=True, + ) + assert result == snapshot + + +async def test_service_get_details_no_warning( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_nina_class: AsyncMock +) -> None: + """Test the get details service when no warning is present.""" + await setup_platform(hass, mock_config_entry, mock_nina_class, []) + + target_entity_id = "binary_sensor.aach_warning_1" + + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_DETAILS, + {ATTR_ENTITY_ID: target_entity_id}, + blocking=True, + return_response=True, + ) + + assert result[target_entity_id] is None diff --git a/tests/components/ollama/test_config_flow.py b/tests/components/ollama/test_config_flow.py index 712f3cfc322f0..77ece15c131c2 100644 --- a/tests/components/ollama/test_config_flow.py +++ b/tests/components/ollama/test_config_flow.py @@ -3,7 +3,7 @@ import asyncio from unittest.mock import ANY, AsyncMock, patch -from httpx import ConnectError +from httpx import HTTPError from ollama import ResponseError import pytest @@ -378,7 +378,8 @@ async def test_reauth_flow_success( ("side_effect", "error"), [ (ResponseError(error="Unauthorized", status_code=401), "invalid_auth"), - (ConnectError(message="Connection failed"), "cannot_connect"), + (ConnectionError("Connection failed"), "cannot_connect"), + (TimeoutError(), "cannot_connect"), ], ) async def test_reauth_flow_errors(hass: HomeAssistant, side_effect, error) -> None: @@ -437,7 +438,8 @@ async def test_reauth_flow_errors(hass: HomeAssistant, side_effect, error) -> No @pytest.mark.parametrize( ("side_effect", "error"), [ - (ConnectError(message=""), "cannot_connect"), + (ConnectionError("Failed to connect to Ollama"), "cannot_connect"), + (TimeoutError(), "cannot_connect"), (RuntimeError(), "unknown"), ], ) @@ -462,7 +464,8 @@ async def test_form_errors(hass: HomeAssistant, side_effect, error) -> None: @pytest.mark.parametrize( ("side_effect", "error"), [ - (ConnectError(message=""), "cannot_connect"), + (ConnectionError(), "cannot_connect"), + (TimeoutError(), "cannot_connect"), (RuntimeError(), "unknown"), ], ) @@ -513,15 +516,24 @@ async def test_form_invalid_url(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "invalid_url"} +@pytest.mark.parametrize( + "side_effect", + [ + TimeoutError(), + ConnectionError("Failed to connect to Ollama"), + HTTPError("HTTP error"), + ], +) async def test_subentry_connection_error( hass: HomeAssistant, mock_init_component, mock_config_entry: MockConfigEntry, + side_effect: Exception, ) -> None: """Test subentry creation when connection to Ollama server fails.""" with patch( "ollama.AsyncClient.list", - side_effect=ConnectError("Connection failed"), + side_effect=side_effect, ): new_flow = await hass.config_entries.subentries.async_init( (mock_config_entry.entry_id, "conversation"), diff --git a/tests/components/portainer/test_services.py b/tests/components/portainer/test_services.py index f5c3ecc0bff01..deeff7cdc98d3 100644 --- a/tests/components/portainer/test_services.py +++ b/tests/components/portainer/test_services.py @@ -162,15 +162,15 @@ async def test_service_recreate_container( [ ( PortainerAuthenticationError("auth"), - "invalid_auth_no_details", + "invalid_auth", ), ( PortainerConnectionError("conn"), - "cannot_connect_no_details", + "cannot_connect", ), ( PortainerTimeoutError("timeout"), - "timeout_connect_no_details", + "timeout_connect", ), ], ) diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr index e65442579b2fe..bff34849d51c3 100644 --- a/tests/components/teslemetry/snapshots/test_climate.ambr +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -410,7 +410,7 @@ # name: test_select_streaming[climate.test_cabin_overheat_protection] StateSnapshot({ 'attributes': ReadOnlyDict({ - : None, + : 26, : 'Test Cabin overheat protection', : list([ , diff --git a/tests/components/velbus/conftest.py b/tests/components/velbus/conftest.py index 12f61e451de16..643f9f73e7a45 100644 --- a/tests/components/velbus/conftest.py +++ b/tests/components/velbus/conftest.py @@ -1,7 +1,7 @@ """Fixtures for the Velbus tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, PropertyMock, patch import pytest from velbusaio.channels import ( @@ -209,6 +209,7 @@ def mock_buttoncounter() -> AsyncMock: channel.get_state.return_value = 100 channel.get_unit.return_value = "W" channel.get_counter_state.return_value = 100 + type(channel).energy = PropertyMock(return_value=100.0) channel.get_counter_unit.return_value = "kWh" return channel diff --git a/tests/components/velbus/test_sensor.py b/tests/components/velbus/test_sensor.py index d89d2de59db6e..a546a7622d2ca 100644 --- a/tests/components/velbus/test_sensor.py +++ b/tests/components/velbus/test_sensor.py @@ -1,10 +1,10 @@ """Velbus sensor platform tests.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, PropertyMock, patch from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -24,3 +24,17 @@ async def test_entities( await init_integration(hass, config_entry) await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_vmb8in_counter_energy_unavailable_when_no_energy( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_buttoncounter: AsyncMock, +) -> None: + """Test VMB8IN-20 counter sensor is unknown when energy has not been received.""" + type(mock_buttoncounter).energy = PropertyMock(return_value=None) + await init_integration(hass, config_entry) + + state = hass.states.get("sensor.input_buttoncounter_counter") + assert state is not None + assert state.state == STATE_UNKNOWN diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index 186bb6680c3b0..c203876e7b355 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -1218,6 +1218,68 @@ 'state': '46', }) # --- +# name: test_all_entities[sensor.model11_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'cooling', + 'heating', + 'standby', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model11_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Mode', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mode', + 'platform': 'vicare', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_mode', + 'unique_id': 'gateway11_zigbee_################-active_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.model11_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'model11 Mode', + : list([ + 'cooling', + 'heating', + 'standby', + ]), + }), + 'context': , + 'entity_id': 'sensor.model11_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heating', + }) +# --- # name: test_all_entities[sensor.model11_signal_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/yardian/snapshots/test_button.ambr b/tests/components/yardian/snapshots/test_button.ambr new file mode 100644 index 0000000000000..b26b7cd70b6ca --- /dev/null +++ b/tests/components/yardian/snapshots/test_button.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_all_entities[button.yardian_smart_sprinkler_stop_irrigation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.yardian_smart_sprinkler_stop_irrigation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Stop irrigation', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop irrigation', + 'platform': 'yardian', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stop_irrigation', + 'unique_id': 'yid123_stop_all', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.yardian_smart_sprinkler_stop_irrigation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Yardian Smart Sprinkler Stop irrigation', + }), + 'context': , + 'entity_id': 'button.yardian_smart_sprinkler_stop_irrigation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/yardian/test_button.py b/tests/components/yardian/test_button.py new file mode 100644 index 0000000000000..6134624de6d0f --- /dev/null +++ b/tests/components/yardian/test_button.py @@ -0,0 +1,46 @@ +"""Validate Yardian button behavior.""" + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.yardian.PLATFORMS", [Platform.BUTTON]) +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_yardian_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all button entities.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@patch("homeassistant.components.yardian.button.BUTTON_REFRESH_DELAY", 0) +async def test_stop_all_button( + hass: HomeAssistant, + mock_yardian_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pressing the stop irrigation button.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.yardian_smart_sprinkler_stop_irrigation"}, + blocking=True, + ) + mock_yardian_client.stop_irrigation.assert_called_once()