From c27e43c57053208716b6531faa4ed4b5913e0db6 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 8 Jun 2026 10:47:12 +0100 Subject: [PATCH 001/404] Moves V2C InstallationVoltage from Sensor to Number (#169771) Co-authored-by: Samuel Cabrero Co-authored-by: Samuel Cabrero Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Samuel Cabrero --- homeassistant/components/v2c/icons.json | 5 + homeassistant/components/v2c/number.py | 24 +- homeassistant/components/v2c/sensor.py | 25 +- homeassistant/components/v2c/strings.json | 13 + homeassistant/components/v2c/util.py | 99 +++++++ tests/components/v2c/fixtures/get_data.json | 1 + .../v2c/snapshots/test_diagnostics.ambr | 4 +- .../components/v2c/snapshots/test_number.ambr | 245 ++++++++++++++++++ .../components/v2c/snapshots/test_sensor.ambr | 58 ----- tests/components/v2c/test_number.py | 57 ++++ 10 files changed, 464 insertions(+), 67 deletions(-) create mode 100644 homeassistant/components/v2c/util.py create mode 100644 tests/components/v2c/snapshots/test_number.ambr create mode 100644 tests/components/v2c/test_number.py diff --git a/homeassistant/components/v2c/icons.json b/homeassistant/components/v2c/icons.json index fe1b4b8a6483..88a890fcb4d3 100644 --- a/homeassistant/components/v2c/icons.json +++ b/homeassistant/components/v2c/icons.json @@ -8,6 +8,11 @@ "default": "mdi:led-on" } }, + "number": { + "voltage_installation": { + "default": "mdi:sine-wave" + } + }, "sensor": { "battery_power": { "default": "mdi:home-battery" diff --git a/homeassistant/components/v2c/number.py b/homeassistant/components/v2c/number.py index 321fa9f5664e..9bb3b572d6be 100644 --- a/homeassistant/components/v2c/number.py +++ b/homeassistant/components/v2c/number.py @@ -11,7 +11,11 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) -from homeassistant.const import EntityCategory, UnitOfElectricCurrent +from homeassistant.const import ( + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -20,13 +24,15 @@ from .entity import V2CBaseEntity MIN_INTENSITY = 6 MAX_INTENSITY = 32 +MIN_VOLTAGE = 1 +MAX_VOLTAGE = 500 @dataclass(frozen=True, kw_only=True) class V2CSettingsNumberEntityDescription(NumberEntityDescription): """Describes V2C EVSE number entity.""" - value_fn: Callable[[TrydanData], int] + value_fn: Callable[[TrydanData], int | None] update_fn: Callable[[Trydan, int], Coroutine[Any, Any, None]] @@ -63,6 +69,18 @@ TRYDAN_NUMBER_SETTINGS = ( value_fn=lambda evse_data: evse_data.max_intensity, update_fn=lambda evse, value: evse.max_intensity(value), ), + V2CSettingsNumberEntityDescription( + key="voltage_installation", + translation_key="voltage_installation", + device_class=NumberDeviceClass.VOLTAGE, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + native_min_value=MIN_VOLTAGE, + native_max_value=MAX_VOLTAGE, + value_fn=lambda evse_data: evse_data.voltage_installation, + update_fn=lambda evse, value: evse.voltage_installation(value), + entity_registry_enabled_default=False, + ), ) @@ -96,7 +114,7 @@ class V2CSettingsNumberEntity(V2CBaseEntity, NumberEntity): self._attr_unique_id = f"{entry_id}_{description.key}" @property - def native_value(self) -> float: + def native_value(self) -> float | None: """Return the state of the setting entity.""" return self.entity_description.value_fn(self.data) diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py index a9c474e5bddb..fd46fbf51d1d 100644 --- a/homeassistant/components/v2c/sensor.py +++ b/homeassistant/components/v2c/sensor.py @@ -15,17 +15,20 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( EntityCategory, + Platform, UnitOfElectricPotential, UnitOfEnergy, UnitOfPower, UnitOfTime, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import V2CConfigEntry, V2CUpdateCoordinator from .entity import V2CBaseEntity +from .util import deprecate_entity _LOGGER = logging.getLogger(__name__) @@ -144,11 +147,25 @@ async def async_setup_entry( ) -> None: """Set up V2C sensor platform.""" coordinator = config_entry.runtime_data + entity_registry = er.async_get(hass) - async_add_entities( - V2CSensorBaseEntity(coordinator, description, config_entry.entry_id) - for description in TRYDAN_SENSORS - ) + entities: list[V2CSensorBaseEntity] = [] + for description in TRYDAN_SENSORS: + if description.key == "voltage_installation" and not deprecate_entity( + hass=hass, + entity_registry=entity_registry, + platform_domain=Platform.SENSOR, + entity_unique_id=f"{config_entry.entry_id}_{description.key}", + issue_id=f"deprecated_sensor_{config_entry.entry_id}_{description.key}", + issue_string="deprecated_sensor", + replacement_entity_unique_id=f"{config_entry.entry_id}_{description.key}", + replacement_entity_id=f"number.evse_{description.key}", + ): + continue + entities.append( + V2CSensorBaseEntity(coordinator, description, config_entry.entry_id) + ) + async_add_entities(entities) class V2CSensorBaseEntity(V2CBaseEntity, SensorEntity): diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index eeb4a849d8c2..b3abf95d0d8e 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -47,6 +47,9 @@ }, "min_intensity": { "name": "Min intensity" + }, + "voltage_installation": { + "name": "Installation voltage" } }, "sensor": { @@ -138,5 +141,15 @@ "name": "Charge point timer" } } + }, + "issues": { + "deprecated_sensor": { + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with `{replacement_entity_id}`.\n\nUpdate your dashboards, templates, automations and scripts to use the replacement entity, then disable the deprecated sensor to have it removed after the next restart.", + "title": "Deprecated sensor detected" + }, + "deprecated_sensor_scripts": { + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with `{replacement_entity_id}`.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nUpdate the above automations or scripts to use the replacement entity, then disable the deprecated sensor to have it removed after the next restart.", + "title": "[%key:component::v2c::issues::deprecated_sensor::title%]" + } } } diff --git a/homeassistant/components/v2c/util.py b/homeassistant/components/v2c/util.py new file mode 100644 index 000000000000..7121d7b7e3b0 --- /dev/null +++ b/homeassistant/components/v2c/util.py @@ -0,0 +1,99 @@ +"""Utility helpers for the v2c integration.""" + +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) + +from .const import DOMAIN + + +def deprecate_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + platform_domain: str, + entity_unique_id: str, + issue_id: str, + issue_string: str, + replacement_entity_unique_id: str, + replacement_entity_id: str, + version: str = "2026.12.0", +) -> bool: + """Create an issue for deprecated entities.""" + if entity_id := entity_registry.async_get_entity_id( + platform_domain, DOMAIN, entity_unique_id + ): + entity_entry = entity_registry.async_get(entity_id) + if not entity_entry: + async_delete_issue(hass, DOMAIN, issue_id) + return False + + items = get_automations_and_scripts_using_entity(hass, entity_id) + if entity_entry.disabled and not items: + entity_registry.async_remove(entity_id) + async_delete_issue(hass, DOMAIN, issue_id) + return False + + translation_key = issue_string + placeholders = { + "entity_id": entity_id, + "entity_name": entity_entry.name or entity_entry.original_name or "Unknown", + "replacement_entity_id": ( + entity_registry.async_get_entity_id( + Platform.NUMBER, DOMAIN, replacement_entity_unique_id + ) + or replacement_entity_id + ), + } + if items: + translation_key = f"{translation_key}_scripts" + placeholders["items"] = "\n".join(items) + + async_create_issue( + hass, + DOMAIN, + issue_id, + breaks_in_ha_version=version, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders=placeholders, + ) + return True + + async_delete_issue(hass, DOMAIN, issue_id) + return False + + +def get_automations_and_scripts_using_entity( + hass: HomeAssistant, + entity_id: str, +) -> list[str]: + """Get automations and scripts using an entity.""" + automations = automations_with_entity(hass, entity_id) + scripts = scripts_with_entity(hass, entity_id) + if not automations and not scripts: + return [] + + entity_registry = er.async_get(hass) + items: list[str] = [] + + for integration, entities in ( + ("automation", automations), + ("script", scripts), + ): + for used_entity_id in entities: + if item := entity_registry.async_get(used_entity_id): + items.append( + f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})" + ) + else: + items.append(f"- `{used_entity_id}`") + + return items diff --git a/tests/components/v2c/fixtures/get_data.json b/tests/components/v2c/fixtures/get_data.json index 1ee52e02aefa..2c082cefb746 100644 --- a/tests/components/v2c/fixtures/get_data.json +++ b/tests/components/v2c/fixtures/get_data.json @@ -3,6 +3,7 @@ "ChargeState": 2, "ReadyState": 0, "ChargePower": 1500.27, + "VoltageInstallation": 230, "ChargeEnergy": 1.8, "SlaveError": 4, "ChargeTime": 4355, diff --git a/tests/components/v2c/snapshots/test_diagnostics.ambr b/tests/components/v2c/snapshots/test_diagnostics.ambr index fb1cbe25015f..0f1d2cd09766 100644 --- a/tests/components/v2c/snapshots/test_diagnostics.ambr +++ b/tests/components/v2c/snapshots/test_diagnostics.ambr @@ -22,8 +22,8 @@ 'unique_id': 'ABC123', 'version': 1, }), - 'data': "TrydanData(ID='ABC123', charge_state=, ready_state=, charge_power=1500.27, voltage_installation=None, charge_energy=1.8, charge_mode=None, slave_error=, charge_time=4355, house_power=0.0, fv_power=0.0, battery_power=0.0, paused=, locked=, timer=, intensity=6, dynamic=, min_intensity=6, max_intensity=16, pause_dynamic=, light_led=25, logo_led=75, dynamic_power_mode=, contracted_power=4600, firmware_version='2.1.7', SSID=None, IP=None, signal_status=None)", + 'data': "TrydanData(ID='ABC123', charge_state=, ready_state=, charge_power=1500.27, voltage_installation=230, charge_energy=1.8, charge_mode=None, slave_error=, charge_time=4355, house_power=0.0, fv_power=0.0, battery_power=0.0, paused=, locked=, timer=, intensity=6, dynamic=, min_intensity=6, max_intensity=16, pause_dynamic=, light_led=25, logo_led=75, dynamic_power_mode=, contracted_power=4600, firmware_version='2.1.7', SSID=None, IP=None, signal_status=None)", 'host_status': 200, - 'raw_data': '{"ID":"ABC123","ChargeState":2,"ReadyState":0,"ChargePower":1500.27,"ChargeEnergy":1.8,"SlaveError":4,"ChargeTime":4355,"HousePower":0.0,"FVPower":0.0,"BatteryPower":0.0,"Paused":0,"Locked":0,"Timer":0,"Intensity":6,"Dynamic":0,"MinIntensity":6,"MaxIntensity":16,"PauseDynamic":0,"LightLED":25,"LogoLED":75,"FirmwareVersion":"2.1.7","DynamicPowerMode":2,"ContractedPower":4600}', + 'raw_data': '{"ID":"ABC123","ChargeState":2,"ReadyState":0,"ChargePower":1500.27,"VoltageInstallation":230,"ChargeEnergy":1.8,"SlaveError":4,"ChargeTime":4355,"HousePower":0.0,"FVPower":0.0,"BatteryPower":0.0,"Paused":0,"Locked":0,"Timer":0,"Intensity":6,"Dynamic":0,"MinIntensity":6,"MaxIntensity":16,"PauseDynamic":0,"LightLED":25,"LogoLED":75,"FirmwareVersion":"2.1.7","DynamicPowerMode":2,"ContractedPower":4600}', }) # --- diff --git a/tests/components/v2c/snapshots/test_number.ambr b/tests/components/v2c/snapshots/test_number.ambr new file mode 100644 index 000000000000..4ba0d5af3f91 --- /dev/null +++ b/tests/components/v2c/snapshots/test_number.ambr @@ -0,0 +1,245 @@ +# serializer version: 1 +# name: test_number[number.evse_1_1_1_1_installation_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 500, + 'min': 1, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.evse_1_1_1_1_installation_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Installation voltage', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Installation voltage', + 'platform': 'v2c', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_installation', + 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_voltage_installation', + 'unit_of_measurement': , + }) +# --- +# name: test_number[number.evse_1_1_1_1_installation_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'EVSE 1.1.1.1 Installation voltage', + 'max': 500, + 'min': 1, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.evse_1_1_1_1_installation_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230', + }) +# --- +# name: test_number[number.evse_1_1_1_1_intensity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 32, + 'min': 6, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.evse_1_1_1_1_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Intensity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Intensity', + 'platform': 'v2c', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'intensity', + 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_intensity', + 'unit_of_measurement': , + }) +# --- +# name: test_number[number.evse_1_1_1_1_intensity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'EVSE 1.1.1.1 Intensity', + 'max': 32, + 'min': 6, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.evse_1_1_1_1_intensity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_number[number.evse_1_1_1_1_max_intensity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 32, + 'min': 6, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.evse_1_1_1_1_max_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Max intensity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max intensity', + 'platform': 'v2c', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_intensity', + 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_max_intensity', + 'unit_of_measurement': , + }) +# --- +# name: test_number[number.evse_1_1_1_1_max_intensity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'EVSE 1.1.1.1 Max intensity', + 'max': 32, + 'min': 6, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.evse_1_1_1_1_max_intensity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_number[number.evse_1_1_1_1_min_intensity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 32, + 'min': 6, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.evse_1_1_1_1_min_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Min intensity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Min intensity', + 'platform': 'v2c', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'min_intensity', + 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_min_intensity', + 'unit_of_measurement': , + }) +# --- +# name: test_number[number.evse_1_1_1_1_min_intensity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'EVSE 1.1.1.1 Min intensity', + 'max': 32, + 'min': 6, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.evse_1_1_1_1_min_intensity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- diff --git a/tests/components/v2c/snapshots/test_sensor.ambr b/tests/components/v2c/snapshots/test_sensor.ambr index 3f0acf335cc5..36f3565f8394 100644 --- a/tests/components/v2c/snapshots/test_sensor.ambr +++ b/tests/components/v2c/snapshots/test_sensor.ambr @@ -289,64 +289,6 @@ 'state': '0.0', }) # --- -# name: test_sensor[sensor.evse_1_1_1_1_installation_voltage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - '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.evse_1_1_1_1_installation_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Installation voltage', - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Installation voltage', - 'platform': 'v2c', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_installation', - 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_voltage_installation', - 'unit_of_measurement': , - }) -# --- -# name: test_sensor[sensor.evse_1_1_1_1_installation_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'EVSE 1.1.1.1 Installation voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.evse_1_1_1_1_installation_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_sensor[sensor.evse_1_1_1_1_ip_address-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/v2c/test_number.py b/tests/components/v2c/test_number.py new file mode 100644 index 000000000000..5d06aaeb3bb2 --- /dev/null +++ b/tests/components/v2c/test_number.py @@ -0,0 +1,57 @@ +"""Test the V2C number platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_number( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_v2c_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test states of the number entities.""" + with patch("homeassistant.components.v2c.PLATFORMS", [Platform.NUMBER]): + await init_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_number_set_value( + hass: HomeAssistant, + mock_v2c_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting number values.""" + with patch("homeassistant.components.v2c.PLATFORMS", [Platform.NUMBER]): + await init_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.evse_1_1_1_1_installation_voltage", + ATTR_VALUE: 240, + }, + blocking=True, + ) + + mock_v2c_client.voltage_installation.assert_called_once_with(240) From 3e3e9af30d94800175e0c0aea0b326ad3d647140 Mon Sep 17 00:00:00 2001 From: bkobus-bbx Date: Mon, 8 Jun 2026 12:04:04 +0200 Subject: [PATCH 002/404] Add state_class to blebox sensors (#173253) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/blebox/icons.json | 3 +++ homeassistant/components/blebox/sensor.py | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/blebox/icons.json b/homeassistant/components/blebox/icons.json index 1cea7723d6d9..3c0f5123dbc0 100644 --- a/homeassistant/components/blebox/icons.json +++ b/homeassistant/components/blebox/icons.json @@ -18,6 +18,9 @@ } }, "sensor": { + "open_status": { + "default": "mdi:window-open" + }, "power_consumption": { "default": "mdi:lightning-bolt" } diff --git a/homeassistant/components/blebox/sensor.py b/homeassistant/components/blebox/sensor.py index bf8486518ccc..d41fb6ff07e9 100644 --- a/homeassistant/components/blebox/sensor.py +++ b/homeassistant/components/blebox/sensor.py @@ -50,21 +50,25 @@ SENSOR_TYPES: tuple[BleBoxSensorEntityDescription, ...] = ( key="pm1", device_class=SensorDeviceClass.PM1, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, ), BleBoxSensorEntityDescription( key="pm2_5", device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, ), BleBoxSensorEntityDescription( key="pm10", device_class=SensorDeviceClass.PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, ), BleBoxSensorEntityDescription( key="temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, ), BleBoxSensorEntityDescription( key="powerConsumption", @@ -76,62 +80,72 @@ SENSOR_TYPES: tuple[BleBoxSensorEntityDescription, ...] = ( key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, ), BleBoxSensorEntityDescription( key="wind", device_class=SensorDeviceClass.WIND_SPEED, native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, ), BleBoxSensorEntityDescription( key="illuminance", device_class=SensorDeviceClass.ILLUMINANCE, native_unit_of_measurement=LIGHT_LUX, + state_class=SensorStateClass.MEASUREMENT, ), BleBoxSensorEntityDescription( key="forwardActiveEnergy", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, ), BleBoxSensorEntityDescription( key="reverseActiveEnergy", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, ), BleBoxSensorEntityDescription( key="reactivePower", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, ), BleBoxSensorEntityDescription( key="activePower", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, ), BleBoxSensorEntityDescription( key="apparentPower", device_class=SensorDeviceClass.APPARENT_POWER, native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, ), BleBoxSensorEntityDescription( key="voltage", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, ), BleBoxSensorEntityDescription( key="current", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + state_class=SensorStateClass.MEASUREMENT, ), BleBoxSensorEntityDescription( key="frequency", device_class=SensorDeviceClass.FREQUENCY, native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, ), BleBoxSensorEntityDescription( key="openStatus", translation_key="open_status", device_class=SensorDeviceClass.ENUM, - icon="mdi:window-open", options=list(OPEN_STATUS.values()), value_fn=lambda v: OPEN_STATUS.get(int(v)) if v is not None else None, ), From 145639d048ab34716737e40245ed27e12799f886 Mon Sep 17 00:00:00 2001 From: Hai-Nam Nguyen Date: Mon, 8 Jun 2026 13:40:37 +0200 Subject: [PATCH 003/404] Add load, grid, and battery sensors to Hypontech (#173150) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/hypontech/coordinator.py | 27 +- homeassistant/components/hypontech/entity.py | 9 +- homeassistant/components/hypontech/sensor.py | 79 ++- .../components/hypontech/strings.json | 18 + tests/components/hypontech/conftest.py | 23 +- .../hypontech/fixtures/monitor.json | 16 + .../hypontech/fixtures/plant_list.json | 2 +- .../hypontech/snapshots/test_sensor.ambr | 505 +++++++++++++++++- 8 files changed, 634 insertions(+), 45 deletions(-) create mode 100644 tests/components/hypontech/fixtures/monitor.json diff --git a/homeassistant/components/hypontech/coordinator.py b/homeassistant/components/hypontech/coordinator.py index 2949d8ea8946..db0a483ce318 100644 --- a/homeassistant/components/hypontech/coordinator.py +++ b/homeassistant/components/hypontech/coordinator.py @@ -1,9 +1,16 @@ """The coordinator for Hypontech Cloud integration.""" +import asyncio from dataclasses import dataclass from datetime import timedelta -from hyponcloud import HyponCloud, OverviewData, PlantData, RequestError +from hyponcloud import ( + HyponCloud, + OverviewData, + PlantData, + PlantMonitorData, + RequestError, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -12,12 +19,20 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER +@dataclass +class HypontechPlant: + """Store a plant together with its real-time monitor data.""" + + info: PlantData + monitor: PlantMonitorData + + @dataclass class HypontechCoordinatorData: """Store coordinator data.""" overview: OverviewData - plants: dict[str, PlantData] + plants: dict[str, HypontechPlant] type HypontechConfigEntry = ConfigEntry[HypontechDataCoordinator] @@ -50,11 +65,17 @@ class HypontechDataCoordinator(DataUpdateCoordinator[HypontechCoordinatorData]): try: overview = await self.api.get_overview() plants = await self.api.get_list() + monitors = await asyncio.gather( + *(self.api.get_monitor(plant.plant_id) for plant in plants) + ) except RequestError as ex: raise UpdateFailed( translation_domain=DOMAIN, translation_key="connection_error" ) from ex return HypontechCoordinatorData( overview=overview, - plants={plant.plant_id: plant for plant in plants}, + plants={ + plant.plant_id: HypontechPlant(info=plant, monitor=monitor) + for plant, monitor in zip(plants, monitors, strict=True) + }, ) diff --git a/homeassistant/components/hypontech/entity.py b/homeassistant/components/hypontech/entity.py index b7109be62263..cb80e7a35f5a 100644 --- a/homeassistant/components/hypontech/entity.py +++ b/homeassistant/components/hypontech/entity.py @@ -1,12 +1,10 @@ """Base entity for the Hypontech Cloud integration.""" -from hyponcloud import PlantData - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import HypontechDataCoordinator +from .coordinator import HypontechDataCoordinator, HypontechPlant class HypontechEntity(CoordinatorEntity[HypontechDataCoordinator]): @@ -36,12 +34,13 @@ class HypontechPlantEntity(CoordinatorEntity[HypontechDataCoordinator]): plant = coordinator.data.plants[plant_id] self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, plant_id)}, - name=plant.plant_name, + name=plant.info.plant_name, manufacturer="Hypontech", + model=plant.info.plant_type, ) @property - def plant(self) -> PlantData: + def plant(self) -> HypontechPlant: """Return the plant data.""" return self.coordinator.data.plants[self.plant_id] diff --git a/homeassistant/components/hypontech/sensor.py b/homeassistant/components/hypontech/sensor.py index 54ba36ceee20..36f7c36f9633 100644 --- a/homeassistant/components/hypontech/sensor.py +++ b/homeassistant/components/hypontech/sensor.py @@ -11,11 +11,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfEnergy, UnitOfPower +from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import HypontechConfigEntry, HypontechDataCoordinator +from .coordinator import HypontechConfigEntry, HypontechDataCoordinator, HypontechPlant from .entity import HypontechEntity, HypontechPlantEntity @@ -36,8 +36,8 @@ class HypontechSensorDescription(SensorEntityDescription): class HypontechPlantSensorDescription(SensorEntityDescription): """Describes Hypontech plant sensor entity.""" - value_fn: Callable[[PlantData], float | None] - unit_fn: Callable[[PlantData], str] | None = None + value_fn: Callable[[HypontechPlant], float | None] + unit_fn: Callable[[HypontechPlant], str] | None = None OVERVIEW_SENSORS: tuple[HypontechSensorDescription, ...] = ( @@ -67,12 +67,24 @@ OVERVIEW_SENSORS: tuple[HypontechSensorDescription, ...] = ( ) PLANT_SENSORS: tuple[HypontechPlantSensorDescription, ...] = ( + # Historically keyed "pv_power" when total power was the only reading (no + # battery support). Now it carries the PV-only power from the monitor + # endpoint; the plant endpoint's total power is exposed as "total_power". HypontechPlantSensorDescription( key="pv_power", + translation_key="pv_power", + native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda c: c.power, - unit_fn=_power_unit, + value_fn=lambda c: c.monitor.power_pv, + ), + HypontechPlantSensorDescription( + key="total_power", + translation_key="total_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda c: c.info.power, + unit_fn=lambda c: _power_unit(c.info), ), HypontechPlantSensorDescription( key="lifetime_energy", @@ -80,7 +92,7 @@ PLANT_SENSORS: tuple[HypontechPlantSensorDescription, ...] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda c: c.e_total, + value_fn=lambda c: c.info.e_total, ), HypontechPlantSensorDescription( key="today_energy", @@ -88,7 +100,44 @@ PLANT_SENSORS: tuple[HypontechPlantSensorDescription, ...] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda c: c.e_today, + value_fn=lambda c: c.info.e_today, + ), + HypontechPlantSensorDescription( + key="load_power", + translation_key="load_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda c: c.monitor.power_load, + ), + HypontechPlantSensorDescription( + key="grid_power", + translation_key="grid_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda c: c.monitor.meter_power, + ), +) + +# Sensors only added for plants that have a battery (storage) system. +BATTERY_SENSORS: tuple[HypontechPlantSensorDescription, ...] = ( + HypontechPlantSensorDescription( + key="battery_power", + translation_key="battery_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + # Positive while the battery is discharging, negative while charging. + value_fn=lambda c: c.monitor.w_cha, + ), + HypontechPlantSensorDescription( + key="battery_state_of_charge", + translation_key="battery_state_of_charge", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda c: c.monitor.soc, ), ) @@ -105,11 +154,15 @@ async def async_setup_entry( HypontechOverviewSensor(coordinator, desc) for desc in OVERVIEW_SENSORS ] - entities.extend( - HypontechPlantSensor(coordinator, plant_id, desc) - for plant_id in coordinator.data.plants - for desc in PLANT_SENSORS - ) + for plant_id, plant in coordinator.data.plants.items(): + entities.extend( + HypontechPlantSensor(coordinator, plant_id, desc) for desc in PLANT_SENSORS + ) + if plant.info.plant_type.endswith("Storage"): + entities.extend( + HypontechPlantSensor(coordinator, plant_id, desc) + for desc in BATTERY_SENSORS + ) async_add_entities(entities) diff --git a/homeassistant/components/hypontech/strings.json b/homeassistant/components/hypontech/strings.json index b2d18800fe0f..637c87d97724 100644 --- a/homeassistant/components/hypontech/strings.json +++ b/homeassistant/components/hypontech/strings.json @@ -36,11 +36,29 @@ }, "entity": { "sensor": { + "battery_power": { + "name": "Battery power" + }, + "battery_state_of_charge": { + "name": "Battery state of charge" + }, + "grid_power": { + "name": "Grid power" + }, "lifetime_energy": { "name": "Lifetime energy" }, + "load_power": { + "name": "Load power" + }, + "pv_power": { + "name": "PV power" + }, "today_energy": { "name": "Today energy" + }, + "total_power": { + "name": "Total power" } } }, diff --git a/tests/components/hypontech/conftest.py b/tests/components/hypontech/conftest.py index 6007696c39ef..eacc6303a684 100644 --- a/tests/components/hypontech/conftest.py +++ b/tests/components/hypontech/conftest.py @@ -1,9 +1,16 @@ """Common fixtures for the Hypontech Cloud tests.""" from collections.abc import Generator +from typing import Any, cast from unittest.mock import AsyncMock, patch -from hyponcloud import AdminInfo, InverterData, OverviewData, PlantData +from hyponcloud import ( + AdminInfo, + InverterData, + OverviewData, + PlantData, + PlantMonitorData, +) import pytest from homeassistant.components.hypontech.const import DOMAIN @@ -55,6 +62,16 @@ def load_inverters_fixture() -> list[InverterData]: return [InverterData.from_dict(item) for item in data["data"]] +@pytest.fixture +def load_monitor_fixture() -> dict[str, PlantMonitorData]: + """Load plant monitor fixture data.""" + data = load_json_object_fixture("monitor.json", DOMAIN) + return { + plant_id: PlantMonitorData.from_dict(cast(dict[str, Any], monitor)) + for plant_id, monitor in data.items() + } + + @pytest.fixture def load_admin_info_fixture() -> AdminInfo: """Load admin info fixture data.""" @@ -73,6 +90,7 @@ def mock_hyponcloud( load_plant_list_fixture: list[PlantData], load_inverters_fixture: list[InverterData], load_admin_info_fixture: AdminInfo, + load_monitor_fixture: dict[str, PlantMonitorData], ) -> Generator[AsyncMock]: """Mock HyponCloud.""" with ( @@ -89,4 +107,7 @@ def mock_hyponcloud( mock_client.get_list.return_value = load_plant_list_fixture mock_client.get_overview.return_value = load_overview_fixture mock_client.get_inverters.return_value = load_inverters_fixture + mock_client.get_monitor.side_effect = lambda plant_id, *args, **kwargs: ( + load_monitor_fixture[plant_id] + ) yield mock_client diff --git a/tests/components/hypontech/fixtures/monitor.json b/tests/components/hypontech/fixtures/monitor.json new file mode 100644 index 000000000000..30a3d89ae1a4 --- /dev/null +++ b/tests/components/hypontech/fixtures/monitor.json @@ -0,0 +1,16 @@ +{ + "1123456789123456789": { + "meter_power": -150.5, + "power_load": 320.0, + "w_cha": 45.2, + "power_pv": 123.0, + "soc": 87.0 + }, + "3123456789123456789": { + "meter_power": 200.0, + "power_load": 1500.0, + "w_cha": 0.0, + "power_pv": 1100.0, + "soc": 50.0 + } +} diff --git a/tests/components/hypontech/fixtures/plant_list.json b/tests/components/hypontech/fixtures/plant_list.json index 08578b296023..7fbf07a09c26 100644 --- a/tests/components/hypontech/fixtures/plant_list.json +++ b/tests/components/hypontech/fixtures/plant_list.json @@ -4,7 +4,7 @@ "status": "offline", "time": "2026-02-16T18:04:50+01:00", "plant_name": "Balcon", - "plant_type": "PvGrid", + "plant_type": "PvGridStorage", "photo": "", "owner_name": "admin@example.com", "country": "United States", diff --git a/tests/components/hypontech/snapshots/test_sensor.ambr b/tests/components/hypontech/snapshots/test_sensor.ambr index f375be4d2673..e13190e2897d 100644 --- a/tests/components/hypontech/snapshots/test_sensor.ambr +++ b/tests/components/hypontech/snapshots/test_sensor.ambr @@ -1,4 +1,175 @@ # serializer version: 1 +# name: test_sensors[sensor.balcon_battery_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.balcon_battery_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery power', + 'platform': 'hypontech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_power', + 'unique_id': '1123456789123456789_battery_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.balcon_battery_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Balcon Battery power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.balcon_battery_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.2', + }) +# --- +# name: test_sensors[sensor.balcon_battery_state_of_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.balcon_battery_state_of_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery state of charge', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery state of charge', + 'platform': 'hypontech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state_of_charge', + 'unique_id': '1123456789123456789_battery_state_of_charge', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.balcon_battery_state_of_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Balcon Battery state of charge', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.balcon_battery_state_of_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '87.0', + }) +# --- +# name: test_sensors[sensor.balcon_grid_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.balcon_grid_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid power', + 'platform': 'hypontech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_power', + 'unique_id': '1123456789123456789_grid_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.balcon_grid_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Balcon Grid power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.balcon_grid_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-150.5', + }) +# --- # name: test_sensors[sensor.balcon_lifetime_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -57,7 +228,7 @@ 'state': '48.0', }) # --- -# name: test_sensors[sensor.balcon_power-entry] +# name: test_sensors[sensor.balcon_load_power-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -73,7 +244,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.balcon_power', + 'entity_id': 'sensor.balcon_load_power', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -81,7 +252,7 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Power', + 'object_id_base': 'Load power', 'options': dict({ 'sensor': dict({ 'suggested_display_precision': 0, @@ -89,26 +260,84 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Power', + 'original_name': 'Load power', 'platform': 'hypontech', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1123456789123456789_pv_power', + 'translation_key': 'load_power', + 'unique_id': '1123456789123456789_load_power', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.balcon_power-state] +# name: test_sensors[sensor.balcon_load_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Balcon Power', + 'friendly_name': 'Balcon Load power', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.balcon_power', + 'entity_id': 'sensor.balcon_load_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '320.0', + }) +# --- +# name: test_sensors[sensor.balcon_pv_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.balcon_pv_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PV power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PV power', + 'platform': 'hypontech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pv_power', + 'unique_id': '1123456789123456789_pv_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.balcon_pv_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Balcon PV power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.balcon_pv_power', 'last_changed': , 'last_reported': , 'last_updated': , @@ -173,6 +402,64 @@ 'state': '1.54', }) # --- +# name: test_sensors[sensor.balcon_total_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.balcon_total_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power', + 'platform': 'hypontech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_power', + 'unique_id': '1123456789123456789_total_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.balcon_total_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Balcon Total power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.balcon_total_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.0', + }) +# --- # name: test_sensors[sensor.overview_lifetime_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -347,6 +634,64 @@ 'state': '1.54', }) # --- +# name: test_sensors[sensor.rooftop_grid_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.rooftop_grid_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid power', + 'platform': 'hypontech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_power', + 'unique_id': '3123456789123456789_grid_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.rooftop_grid_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Rooftop Grid power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.rooftop_grid_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '200.0', + }) +# --- # name: test_sensors[sensor.rooftop_lifetime_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -405,7 +750,7 @@ 'state': '100.0', }) # --- -# name: test_sensors[sensor.rooftop_power-entry] +# name: test_sensors[sensor.rooftop_load_power-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -421,7 +766,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.rooftop_power', + 'entity_id': 'sensor.rooftop_load_power', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -429,38 +774,96 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Power', + 'object_id_base': 'Load power', 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Power', + 'original_name': 'Load power', 'platform': 'hypontech', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '3123456789123456789_pv_power', - 'unit_of_measurement': , + 'translation_key': 'load_power', + 'unique_id': '3123456789123456789_load_power', + 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.rooftop_power-state] +# name: test_sensors[sensor.rooftop_load_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Rooftop Power', + 'friendly_name': 'Rooftop Load power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.rooftop_power', + 'entity_id': 'sensor.rooftop_load_power', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.1', + 'state': '1500.0', + }) +# --- +# name: test_sensors[sensor.rooftop_pv_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.rooftop_pv_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PV power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PV power', + 'platform': 'hypontech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pv_power', + 'unique_id': '3123456789123456789_pv_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.rooftop_pv_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Rooftop PV power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.rooftop_pv_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1100.0', }) # --- # name: test_sensors[sensor.rooftop_today_energy-entry] @@ -521,3 +924,61 @@ 'state': '2.5', }) # --- +# name: test_sensors[sensor.rooftop_total_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.rooftop_total_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power', + 'platform': 'hypontech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_power', + 'unique_id': '3123456789123456789_total_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.rooftop_total_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Rooftop Total power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.rooftop_total_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1', + }) +# --- From 78cc155e56253550fa0743e268bc949c528c6283 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:45:29 +0100 Subject: [PATCH 004/404] Update PARALLEL_UPDATES to 0 for Squeezebox platforms (#172906) --- homeassistant/components/squeezebox/media_player.py | 2 +- homeassistant/components/squeezebox/switch.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 0e7484b96a43..969a07f1fba3 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -74,7 +74,7 @@ ATTR_QUERY_RESULT = "query_result" _LOGGER = logging.getLogger(__name__) -PARALLEL_UPDATES = 1 +PARALLEL_UPDATES = 0 ATTR_OTHER_PLAYER = "other_player" diff --git a/homeassistant/components/squeezebox/switch.py b/homeassistant/components/squeezebox/switch.py index 315228d3ffdc..33bf9dc47bc9 100644 --- a/homeassistant/components/squeezebox/switch.py +++ b/homeassistant/components/squeezebox/switch.py @@ -22,7 +22,7 @@ from .entity import SqueezeboxEntity _LOGGER = logging.getLogger(__name__) -PARALLEL_UPDATES = 1 +PARALLEL_UPDATES = 0 async def async_setup_entry( From c9ad482293f20d1efdb8ae4f1ca99047b2959d94 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 8 Jun 2026 13:51:10 +0200 Subject: [PATCH 005/404] Adjust ONVIF event fallbacks for battery cameras (#173214) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/reolink/host.py | 23 +++++++----- tests/components/reolink/test_host.py | 48 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 42f6b21c4966..ab80e1396a2d 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -369,7 +369,11 @@ class ReolinkHost: ) # start long polling if ONVIF push failed immediately - if not self._onvif_push_supported and not self._api.baichuan.privacy_mode(): + if ( + self._onvif_long_poll_supported + and not self._onvif_push_supported + and not self._api.baichuan.privacy_mode() + ): _LOGGER.debug( "Camera model %s does not support ONVIF push," " using ONVIF long polling instead", @@ -378,14 +382,8 @@ class ReolinkHost: try: await self._async_start_long_polling(initial=True) except NotSupportedError: - _LOGGER.debug( - "Camera model %s does not support ONVIF long" - " polling, using fast polling instead", - self._api.model, - ) self._onvif_long_poll_supported = False await self._api.unsubscribe() - await self._async_poll_all_motion() else: self._cancel_long_poll_check = async_call_later( self._hass, @@ -393,6 +391,13 @@ class ReolinkHost: self._async_check_onvif_long_poll, ) + if not self._onvif_long_poll_supported: + _LOGGER.debug( + "Camera model %s does not support ONVIF push and long polling, using fast polling instead", + self._api.model, + ) + await self._async_poll_all_motion() + self._cancel_tcp_push_check = None async def _async_check_onvif(self, *_: Any) -> None: @@ -822,7 +827,7 @@ class ReolinkHost: return try: - if self._api.session_active: + if self._api.session_active and not self._api.baichuan.privacy_mode(): await self._api.get_motion_state_all_ch() except ReolinkError as err: if not self._fast_poll_error: @@ -834,7 +839,7 @@ class ReolinkHost: ) self._fast_poll_error = True else: - if self._api.session_active: + if self._api.session_active and not self._api.baichuan.privacy_mode(): self._fast_poll_error = False finally: # schedule next poll diff --git a/tests/components/reolink/test_host.py b/tests/components/reolink/test_host.py index bd8ea3eec089..475dc1655587 100644 --- a/tests/components/reolink/test_host.py +++ b/tests/components/reolink/test_host.py @@ -301,6 +301,54 @@ async def test_ONVIF_not_supported( assert config_entry.state is ConfigEntryState.LOADED +async def test_immediate_fast_polling_ONVIF_not_supported( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, + reolink_host: MagicMock, +) -> None: + """Test immediate fast polling if ONVIF not supported.""" + + def test_supported(ch, key): + """Test supported function.""" + if key == "ONVIF": + return False + return True + + reolink_host.supported = test_supported + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + freezer.tick(timedelta(seconds=FIRST_TCP_PUSH_TIMEOUT)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # ONVIF push and long poll subscription not called + assert not reolink_host.subscribe.called + # Fast polling called + assert reolink_host.get_motion_state_all_ch.called + + # test fast polling paused when privacy mode activated + reolink_host.baichuan.privacy_mode.return_value = True + reolink_host.get_motion_state_all_ch.reset_mock() + assert not reolink_host.get_motion_state_all_ch.called + freezer.tick(timedelta(seconds=POLL_INTERVAL_NO_PUSH)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert not reolink_host.get_motion_state_all_ch.called + + # test fast polling resumes when privacy mode deactivated + reolink_host.baichuan.privacy_mode.return_value = False + freezer.tick(timedelta(seconds=POLL_INTERVAL_NO_PUSH)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert reolink_host.get_motion_state_all_ch.called + + async def test_renew( hass: HomeAssistant, freezer: FrozenDateTimeFactory, From e4b5818b56dde578e74f0ee53ac148a6a6ba5e3d Mon Sep 17 00:00:00 2001 From: Crocmagnon Date: Mon, 8 Jun 2026 14:00:09 +0200 Subject: [PATCH 006/404] ovhcloud_ai_endpoints: add reconfigure flow (#172583) --- .../ovhcloud_ai_endpoints/config_flow.py | 75 +++++++ .../ovhcloud_ai_endpoints/quality_scale.yaml | 2 +- .../ovhcloud_ai_endpoints/strings.json | 24 ++- .../ovhcloud_ai_endpoints/test_config_flow.py | 183 ++++++++++++++++++ 4 files changed, 282 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ovhcloud_ai_endpoints/config_flow.py b/homeassistant/components/ovhcloud_ai_endpoints/config_flow.py index cb32d0044112..fa7912b937af 100644 --- a/homeassistant/components/ovhcloud_ai_endpoints/config_flow.py +++ b/homeassistant/components/ovhcloud_ai_endpoints/config_flow.py @@ -113,6 +113,36 @@ class OVHcloudAIEndpointsConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure the API key on an existing entry.""" + errors: dict[str, str] = {} + entry = self._get_reconfigure_entry() + if user_input is not None: + self._async_abort_entries_match(user_input) + client = _create_client(self.hass, user_input[CONF_API_KEY]) + try: + await _validate_api_key(client) + except AuthenticationError, PermissionDeniedError: + errors["base"] = "invalid_auth" + except OpenAIError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + entry, data_updates=user_input + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_REAUTH_DATA_SCHEMA, user_input or entry.data + ), + errors=errors, + ) + class ConversationFlowHandler(ConfigSubentryFlow): """Handle conversation subentry flow.""" @@ -136,6 +166,51 @@ class ConversationFlowHandler(ConfigSubentryFlow): self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy() return await self.async_step_init(user_input) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Reconfigure a conversation agent (prompt + LLM APIs; model is fixed).""" + subentry = self._get_reconfigure_subentry() + existing = subentry.data + + if user_input is not None: + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) + user_input[CONF_MODEL] = existing[CONF_MODEL] + return self.async_update_and_abort( + self._get_entry(), subentry, data=user_input + ) + + hass_apis: list[SelectOptionDict] = [ + SelectOptionDict(label=api.name, value=api.id) + for api in llm.async_get_apis(self.hass) + ] + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": existing.get( + CONF_PROMPT, + RECOMMENDED_CONVERSATION_OPTIONS[CONF_PROMPT], + ) + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + default=existing.get( + CONF_LLM_HASS_API, + RECOMMENDED_CONVERSATION_OPTIONS[CONF_LLM_HASS_API], + ), + ): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), + } + ), + ) + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: diff --git a/homeassistant/components/ovhcloud_ai_endpoints/quality_scale.yaml b/homeassistant/components/ovhcloud_ai_endpoints/quality_scale.yaml index cef29e0aee56..607da92b1889 100644 --- a/homeassistant/components/ovhcloud_ai_endpoints/quality_scale.yaml +++ b/homeassistant/components/ovhcloud_ai_endpoints/quality_scale.yaml @@ -82,7 +82,7 @@ rules: comment: conversation entity name comes from subentry title exception-translations: todo icon-translations: todo - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: the integration has no repairs diff --git a/homeassistant/components/ovhcloud_ai_endpoints/strings.json b/homeassistant/components/ovhcloud_ai_endpoints/strings.json index ad9c6638a994..203bfdb1a610 100644 --- a/homeassistant/components/ovhcloud_ai_endpoints/strings.json +++ b/homeassistant/components/ovhcloud_ai_endpoints/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -19,6 +20,15 @@ }, "description": "The OVHcloud AI Endpoints API key is no longer valid. Please enter a new one." }, + "reconfigure": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::ovhcloud_ai_endpoints::config::step::user::data_description::api_key%]" + }, + "description": "Update the API key used to authenticate with OVHcloud AI Endpoints." + }, "user": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]" @@ -34,6 +44,7 @@ "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "entry_not_loaded": "The main integration entry is not loaded. Please ensure the integration is loaded before configuring.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "entry_type": "Conversation agent", @@ -53,6 +64,17 @@ "prompt": "Instruct how the LLM should respond. This can be a template." }, "description": "Configure the conversation agent" + }, + "reconfigure": { + "data": { + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "prompt": "[%key:common::config_flow::data::prompt%]" + }, + "data_description": { + "llm_hass_api": "[%key:component::ovhcloud_ai_endpoints::config_subentries::conversation::step::init::data_description::llm_hass_api%]", + "prompt": "[%key:component::ovhcloud_ai_endpoints::config_subentries::conversation::step::init::data_description::prompt%]" + }, + "description": "Update the prompt and Home Assistant LLM APIs for this conversation agent. Create a new conversation agent to use a different model." } } } diff --git a/tests/components/ovhcloud_ai_endpoints/test_config_flow.py b/tests/components/ovhcloud_ai_endpoints/test_config_flow.py index f77972c439fe..1b04d662bb5e 100644 --- a/tests/components/ovhcloud_ai_endpoints/test_config_flow.py +++ b/tests/components/ovhcloud_ai_endpoints/test_config_flow.py @@ -318,3 +318,186 @@ async def test_reauth_flow_errors( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert mock_config_entry.data[CONF_API_KEY] == "new_key" + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reconfigure flow updates the API key.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "new_key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new_key" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + AuthenticationError( + message="invalid key", + response=httpx.Response( + status_code=401, + request=httpx.Request(method="POST", url="https://example.com"), + ), + body=None, + ), + "invalid_auth", + ), + (OpenAIError("boom"), "cannot_connect"), + (Exception("boom"), "unknown"), + ], +) +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test errors during reconfigure and recovery.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_openai_client.chat.completions.create.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "new_key"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_openai_client.chat.completions.create.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "new_key"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new_key" + + +async def test_reconfigure_flow_duplicate( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure aborts when the new API key belongs to another entry.""" + mock_config_entry.add_to_hass(hass) + other_entry = MockConfigEntry( + title="OVHcloud AI Endpoints", + domain=DOMAIN, + data={CONF_API_KEY: "other_key"}, + ) + other_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "other_key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reconfigure_flow_same_key( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring with the same API key succeeds (entry excluded from match).""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "bla"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_API_KEY] == "bla" + + +async def test_reconfigure_conversation_agent( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring a conversation agent updates the prompt and LLM APIs.""" + await setup_integration(hass, mock_config_entry, mock_openai_client) + + subentry_id = next(iter(mock_config_entry.subentries)) + + result = await mock_config_entry.start_subentry_reconfigure_flow(hass, subentry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert CONF_MODEL not in result["data_schema"].schema + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_PROMPT: "updated prompt", + CONF_LLM_HASS_API: ["assist"], + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + subentry = mock_config_entry.subentries[subentry_id] + assert subentry.data[CONF_PROMPT] == "updated prompt" + assert subentry.data[CONF_LLM_HASS_API] == ["assist"] + assert subentry.data[CONF_MODEL] == "Meta-Llama-3_3-70B-Instruct" + + +async def test_reconfigure_conversation_agent_clears_llm_api( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that clearing the LLM API removes the key from the subentry data.""" + await setup_integration(hass, mock_config_entry, mock_openai_client) + + subentry_id = next(iter(mock_config_entry.subentries)) + + result = await mock_config_entry.start_subentry_reconfigure_flow(hass, subentry_id) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_PROMPT: "updated prompt", + CONF_LLM_HASS_API: [], + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + subentry = mock_config_entry.subentries[subentry_id] + assert subentry.data[CONF_PROMPT] == "updated prompt" + assert CONF_LLM_HASS_API not in subentry.data + assert subentry.data[CONF_MODEL] == "Meta-Llama-3_3-70B-Instruct" From 1a4a95df83ca7b460906f41eb18a7be985620d41 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 8 Jun 2026 15:11:41 +0200 Subject: [PATCH 007/404] Use query_dns from aiodns in dnsip (#173257) --- homeassistant/components/dnsip/__init__.py | 4 +- homeassistant/components/dnsip/config_flow.py | 2 +- homeassistant/components/dnsip/sensor.py | 19 +++-- tests/components/dnsip/__init__.py | 69 ++++++++++++++++--- 4 files changed, 78 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py index 96315a92e2d3..a6d94bcf6f20 100644 --- a/homeassistant/components/dnsip/__init__.py +++ b/homeassistant/components/dnsip/__init__.py @@ -51,7 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo tcp_port=entry.options[CONF_PORT], udp_port=entry.options[CONF_PORT], ) - queries.append(resolver_ipv4.query(hostname, "A")) + queries.append(resolver_ipv4.query_dns(hostname, "A")) if entry.data[CONF_IPV6]: resolver_ipv6 = aiodns.DNSResolver( @@ -59,7 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo tcp_port=entry.options[CONF_PORT_IPV6], udp_port=entry.options[CONF_PORT_IPV6], ) - queries.append(resolver_ipv6.query(hostname, "AAAA")) + queries.append(resolver_ipv6.query_dns(hostname, "AAAA")) async def _close_resolvers() -> None: if resolver_ipv4 is not None: diff --git a/homeassistant/components/dnsip/config_flow.py b/homeassistant/components/dnsip/config_flow.py index 0d65bb63226a..45ed2709bcf7 100644 --- a/homeassistant/components/dnsip/config_flow.py +++ b/homeassistant/components/dnsip/config_flow.py @@ -72,7 +72,7 @@ async def async_validate_hostname( _resolver = aiodns.DNSResolver( nameservers=[resolver], udp_port=port, tcp_port=port ) - result = bool(await _resolver.query(hostname, qtype)) + result = bool(await _resolver.query_dns(hostname, qtype)) return result diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index ca965f330195..f3dd9d216825 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Literal import aiodns from aiodns.error import DNSError +import pycares from homeassistant.components.sensor import SensorEntity from homeassistant.const import CONF_NAME, CONF_PORT @@ -148,7 +149,7 @@ class WanIpSensor(SensorEntity): response = None try: async with asyncio.timeout(10): - response = await self._resolver.query(self.hostname, self.querytype) + response = await self._resolver.query_dns(self.hostname, self.querytype) except TimeoutError as err: _LOGGER.debug("Timeout while resolving host: %s", err) await self._resolver.close() @@ -157,9 +158,19 @@ class WanIpSensor(SensorEntity): await self._resolver.close() if response: - sorted_ips = sort_ips( - [res.host for res in response], querytype=self.querytype - ) + if TYPE_CHECKING: + assert all( + isinstance(res.data, (pycares.ARecordData, pycares.AAAARecordData)) + for res in response.answer + ) + _ips = [] + for res in response.answer: + if TYPE_CHECKING: + assert isinstance( + res.data, (pycares.ARecordData, pycares.AAAARecordData) + ) + _ips.append(res.data.addr) + sorted_ips = sort_ips(_ips, querytype=self.querytype) self._attr_native_value = sorted_ips[0] self._attr_extra_state_attributes["ip_addresses"] = sorted_ips self._attr_available = True diff --git a/tests/components/dnsip/__init__.py b/tests/components/dnsip/__init__.py index d1d0b95ff242..debc7b7f60b7 100644 --- a/tests/components/dnsip/__init__.py +++ b/tests/components/dnsip/__init__.py @@ -1,5 +1,7 @@ """Tests for the DNS IP integration.""" +import pycares + class QueryResult: """Return Query results.""" @@ -23,19 +25,68 @@ class RetrieveDNS: self.error = error self._closed = False - async def query(self, hostname, qtype) -> list[QueryResult]: - """Return information.""" + async def query_dns( + self, host: str, qtype: str, qclass: str | None = None + ) -> pycares.DNSResult: + """Return dns information.""" if self.error: raise self.error if qtype == "AAAA": - results = [ - QueryResult("2001:db8:77::face:b00c"), - QueryResult("2001:db8:77::dead:beef"), - QueryResult("2001:db8::77:dead:beef"), - QueryResult("2001:db8:66::dead:beef"), - ] + results = pycares.DNSResult( + answer=[ + pycares.DNSRecord( + name="test", + type=pycares.QUERY_TYPE_AAAA, + record_class=pycares.QUERY_CLASS_IN, + data=pycares.AAAARecordData(addr="2001:db8:77::face:b00c"), + ttl=60, + ), + pycares.DNSRecord( + name="test", + type=pycares.QUERY_TYPE_AAAA, + record_class=pycares.QUERY_CLASS_IN, + data=pycares.AAAARecordData(addr="2001:db8:77::dead:beef"), + ttl=60, + ), + pycares.DNSRecord( + name="test", + type=pycares.QUERY_TYPE_AAAA, + record_class=pycares.QUERY_CLASS_IN, + data=pycares.AAAARecordData(addr="2001:db8::77:dead:beef"), + ttl=60, + ), + pycares.DNSRecord( + name="test", + type=pycares.QUERY_TYPE_AAAA, + record_class=pycares.QUERY_CLASS_IN, + data=pycares.AAAARecordData(addr="2001:db8:66::dead:beef"), + ttl=60, + ), + ], + authority=[], + additional=[], + ) else: - results = [QueryResult("1.2.3.4"), QueryResult("1.1.1.1")] + results = pycares.DNSResult( + answer=[ + pycares.DNSRecord( + name="test", + type=pycares.QUERY_TYPE_A, + record_class=pycares.QUERY_CLASS_IN, + data=pycares.ARecordData(addr="1.2.3.4"), + ttl=60, + ), + pycares.DNSRecord( + name="test", + type=pycares.QUERY_TYPE_A, + record_class=pycares.QUERY_CLASS_IN, + data=pycares.ARecordData(addr="1.1.1.1"), + ttl=60, + ), + ], + authority=[], + additional=[], + ) return results @property From df4fbc91f9f2830b2c27f95dab9b1b63d01df45f Mon Sep 17 00:00:00 2001 From: Martin Claesson <43297668+Claeysson@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:42:34 +0200 Subject: [PATCH 008/404] Add Kiosker service platform (#171094) --- homeassistant/components/kiosker/__init__.py | 12 + homeassistant/components/kiosker/const.py | 13 + homeassistant/components/kiosker/icons.json | 8 + .../components/kiosker/quality_scale.yaml | 16 +- homeassistant/components/kiosker/services.py | 168 +++++++++++ .../components/kiosker/services.yaml | 62 +++++ homeassistant/components/kiosker/strings.json | 70 +++++ .../kiosker/snapshots/test_services.ambr | 52 ++++ tests/components/kiosker/test_services.py | 261 ++++++++++++++++++ 9 files changed, 650 insertions(+), 12 deletions(-) create mode 100644 homeassistant/components/kiosker/services.py create mode 100644 homeassistant/components/kiosker/services.yaml create mode 100644 tests/components/kiosker/snapshots/test_services.ambr create mode 100644 tests/components/kiosker/test_services.py diff --git a/homeassistant/components/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py index 2291eb14ec89..cd48bb4e4d58 100644 --- a/homeassistant/components/kiosker/__init__.py +++ b/homeassistant/components/kiosker/__init__.py @@ -2,8 +2,14 @@ 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 DOMAIN from .coordinator import KioskerConfigEntry, KioskerDataUpdateCoordinator +from .services import async_setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) _PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -13,6 +19,12 @@ _PLATFORMS: list[Platform] = [ ] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Kiosker integration.""" + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool: """Set up Kiosker from a config entry.""" diff --git a/homeassistant/components/kiosker/const.py b/homeassistant/components/kiosker/const.py index 1ddb268c90a0..446ceb2f9f1a 100644 --- a/homeassistant/components/kiosker/const.py +++ b/homeassistant/components/kiosker/const.py @@ -8,3 +8,16 @@ POLL_INTERVAL = 15 DEFAULT_SSL = False DEFAULT_SSL_VERIFY = False REFRESH_DELAY = 0.5 + +# Service attribute keys +ATTR_URL = "url" +ATTR_VISIBLE = "visible" +ATTR_TEXT = "text" +ATTR_BACKGROUND = "background" +ATTR_FOREGROUND = "foreground" +ATTR_EXPIRE = "expire" +ATTR_DISMISSIBLE = "dismissible" +ATTR_BUTTON_BACKGROUND = "button_background" +ATTR_BUTTON_FOREGROUND = "button_foreground" +ATTR_BUTTON_TEXT = "button_text" +ATTR_SOUND = "sound" diff --git a/homeassistant/components/kiosker/icons.json b/homeassistant/components/kiosker/icons.json index d1b58952f63f..d2fd470ea71b 100644 --- a/homeassistant/components/kiosker/icons.json +++ b/homeassistant/components/kiosker/icons.json @@ -65,5 +65,13 @@ "default": "mdi:power-sleep" } } + }, + "services": { + "navigate_url": { + "service": "mdi:web" + }, + "set_blackout": { + "service": "mdi:monitor-off" + } } } diff --git a/homeassistant/components/kiosker/quality_scale.yaml b/homeassistant/components/kiosker/quality_scale.yaml index 36e0f730ed92..cacfffd02610 100644 --- a/homeassistant/components/kiosker/quality_scale.yaml +++ b/homeassistant/components/kiosker/quality_scale.yaml @@ -1,23 +1,17 @@ rules: # Bronze - action-setup: - status: exempt - comment: Integration does not register custom 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: Integration does not provide custom actions to document + docs-actions: done docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done - entity-event-setup: - status: exempt - comment: Integration is polling-only and does not subscribe to external events + entity-event-setup: done entity-unique-id: done has-entity-name: done runtime-data: done @@ -26,9 +20,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: Integration does not provide custom actions + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done diff --git a/homeassistant/components/kiosker/services.py b/homeassistant/components/kiosker/services.py new file mode 100644 index 000000000000..cd86775e3d74 --- /dev/null +++ b/homeassistant/components/kiosker/services.py @@ -0,0 +1,168 @@ +"""Services for the Kiosker integration.""" + +from collections.abc import Awaitable, Callable, Coroutine +import functools +from typing import Any + +from kiosker import ( + AuthenticationError, + BadRequestError, + Blackout, + ConnectionError, + IPAuthenticationError, + TLSVerificationError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_DEVICE_ID, ATTR_ICON +from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + selector, +) + +from .const import ( + ATTR_BACKGROUND, + ATTR_BUTTON_BACKGROUND, + ATTR_BUTTON_FOREGROUND, + ATTR_BUTTON_TEXT, + ATTR_DISMISSIBLE, + ATTR_EXPIRE, + ATTR_FOREGROUND, + ATTR_SOUND, + ATTR_TEXT, + ATTR_URL, + ATTR_VISIBLE, + DOMAIN, +) +from .coordinator import KioskerDataUpdateCoordinator + +NAVIGATE_URL_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_URL): str, + } +) + +SET_BLACKOUT_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Optional(ATTR_VISIBLE, default=True): cv.boolean, + vol.Optional(ATTR_TEXT): str, + vol.Optional(ATTR_BACKGROUND, default=[0, 0, 0]): selector.ColorRGBSelector(), + vol.Optional( + ATTR_FOREGROUND, default=[255, 255, 255] + ): selector.ColorRGBSelector(), + vol.Optional(ATTR_ICON): str, + vol.Optional(ATTR_EXPIRE, default=60): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100000) + ), + vol.Optional(ATTR_DISMISSIBLE, default=False): cv.boolean, + vol.Optional( + ATTR_BUTTON_BACKGROUND, default=[255, 255, 255] + ): selector.ColorRGBSelector(), + vol.Optional( + ATTR_BUTTON_FOREGROUND, default=[0, 0, 0] + ): selector.ColorRGBSelector(), + vol.Optional(ATTR_BUTTON_TEXT): str, + vol.Optional(ATTR_SOUND): str, + } +) + + +def handle_kiosker_api_errors( + func: Callable[[ServiceCall], Awaitable[None]], +) -> Callable[[ServiceCall], Coroutine[Any, Any, ServiceResponse]]: + """Decorator to handle Kiosker API errors consistently across all service calls.""" + + @functools.wraps(func) + async def wrapper(call: ServiceCall) -> ServiceResponse: + try: + await func(call) + except ConnectionError as ex: + raise HomeAssistantError(f"Unable to connect to Kiosker: {ex}") from ex + except AuthenticationError as ex: + raise ServiceValidationError( + "Authentication failed. Check your API token." + ) from ex + except IPAuthenticationError as ex: + raise ServiceValidationError( + "IP authentication failed. Check your IP whitelist." + ) from ex + except TLSVerificationError as ex: + raise ServiceValidationError(f"TLS verification failed: {ex}") from ex + except BadRequestError as ex: + raise ServiceValidationError(f"Bad request: {ex}") from ex + else: + return None + + return wrapper + + +async def _get_coordinator( + call: ServiceCall, +) -> KioskerDataUpdateCoordinator: + """Get the coordinator for the targeted device.""" + registry = dr.async_get(call.hass) + device_id: str = call.data[ATTR_DEVICE_ID] + device = registry.async_get(device_id) + + if device: + for entry_id in device.config_entries: + entry = call.hass.config_entries.async_get_entry(entry_id) + if entry and entry.domain == DOMAIN: + if entry.state != ConfigEntryState.LOADED: + raise HomeAssistantError(f"{entry.title} is not loaded") + return entry.runtime_data + + raise ServiceValidationError(f"No {DOMAIN} devices found in targeted selection") + + +def _rgb_to_hex(rgb: list[int]) -> str: + """Convert an [r, g, b] list to a hex color string.""" + return f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}" + + +@handle_kiosker_api_errors +async def navigate_url(call: ServiceCall) -> None: + """Navigate to a URL on the Kiosker device.""" + coordinator = await _get_coordinator(call) + await call.hass.async_add_executor_job( + coordinator.api.navigate_url, call.data[ATTR_URL] + ) + + +@handle_kiosker_api_errors +async def set_blackout(call: ServiceCall) -> None: + """Set blackout mode on the Kiosker device.""" + blackout = Blackout( + visible=call.data[ATTR_VISIBLE], + text=call.data.get(ATTR_TEXT), + background=_rgb_to_hex(call.data[ATTR_BACKGROUND]), + foreground=_rgb_to_hex(call.data[ATTR_FOREGROUND]), + icon=call.data.get(ATTR_ICON), + expire=call.data[ATTR_EXPIRE], + dismissible=call.data[ATTR_DISMISSIBLE], + buttonBackground=_rgb_to_hex(call.data[ATTR_BUTTON_BACKGROUND]), + buttonForeground=_rgb_to_hex(call.data[ATTR_BUTTON_FOREGROUND]), + buttonText=call.data.get(ATTR_BUTTON_TEXT), + sound=call.data.get(ATTR_SOUND), + ) + + coordinator = await _get_coordinator(call) + await call.hass.async_add_executor_job(coordinator.api.blackout_set, blackout) + await coordinator.async_request_refresh() + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Kiosker integration.""" + hass.services.async_register( + DOMAIN, "navigate_url", navigate_url, schema=NAVIGATE_URL_SCHEMA + ) + hass.services.async_register( + DOMAIN, "set_blackout", set_blackout, schema=SET_BLACKOUT_SCHEMA + ) diff --git a/homeassistant/components/kiosker/services.yaml b/homeassistant/components/kiosker/services.yaml new file mode 100644 index 000000000000..d42de2b91741 --- /dev/null +++ b/homeassistant/components/kiosker/services.yaml @@ -0,0 +1,62 @@ +navigate_url: + fields: + device_id: + required: true + selector: + device: + integration: kiosker + url: + required: true + selector: + text: + +set_blackout: + fields: + device_id: + required: true + selector: + device: + integration: kiosker + visible: + default: true + selector: + boolean: + text: + selector: + text: + background: + default: [0, 0, 0] + selector: + color_rgb: + foreground: + default: [255, 255, 255] + selector: + color_rgb: + icon: + selector: + text: + expire: + default: 60 + selector: + number: + min: 0 + max: 3600 + unit_of_measurement: seconds + dismissible: + default: false + selector: + boolean: + button_background: + default: [255, 255, 255] + selector: + color_rgb: + button_foreground: + default: [0, 0, 0] + selector: + color_rgb: + button_text: + selector: + text: + sound: + selector: + text: diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index 8e63f276b585..beabd72e9dd5 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -104,5 +104,75 @@ "name": "Disable screensaver" } } + }, + "services": { + "navigate_url": { + "description": "Navigate to a specific URL", + "fields": { + "device_id": { + "description": "The Kiosker device to control", + "name": "Device" + }, + "url": { + "description": "The URL to navigate to", + "name": "URL" + } + }, + "name": "Navigate to URL" + }, + "set_blackout": { + "description": "Set blackout screen with custom message", + "fields": { + "background": { + "description": "Background color in rgb format", + "name": "Background color" + }, + "button_background": { + "description": "Background color of the dismiss button in rgb format", + "name": "Button background color" + }, + "button_foreground": { + "description": "Text color of the dismiss button in rgb format", + "name": "Button foreground color" + }, + "button_text": { + "description": "Text to display on the dismiss button", + "name": "Button text" + }, + "device_id": { + "description": "The Kiosker device to control", + "name": "Device" + }, + "dismissible": { + "description": "Whether the blackout can be dismissed by user interaction", + "name": "Dismissible" + }, + "expire": { + "description": "Time in seconds before the blackout expires", + "name": "Expire time" + }, + "foreground": { + "description": "Text color in rgb format", + "name": "Foreground color" + }, + "icon": { + "description": "Icon to display (SF Symbols name)", + "name": "Icon" + }, + "sound": { + "description": "Sound to play when blackout is displayed (SystemSoundID, e.g., 1007)", + "name": "Sound" + }, + "text": { + "description": "Text to display on blackout screen", + "name": "Text" + }, + "visible": { + "description": "Whether the blackout is visible", + "name": "Visible" + } + }, + "name": "Set blackout" + } } } diff --git a/tests/components/kiosker/snapshots/test_services.ambr b/tests/components/kiosker/snapshots/test_services.ambr new file mode 100644 index 000000000000..92b5fa4e13e1 --- /dev/null +++ b/tests/components/kiosker/snapshots/test_services.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_navigate_url + _Call( + tuple( + 'https://example.com', + ), + dict({ + }), + ) +# --- +# name: test_set_blackout[all_fields] + _Call( + tuple( + dict({ + 'background': '#000000', + 'buttonBackground': '#ff0000', + 'buttonForeground': '#00ff00', + 'buttonText': 'Dismiss', + 'dismissible': True, + 'expire': 30, + 'foreground': '#ffffff', + 'icon': 'star', + 'sound': '1007', + 'text': 'Hello World', + 'visible': True, + }), + ), + dict({ + }), + ) +# --- +# name: test_set_blackout[defaults] + _Call( + tuple( + dict({ + 'background': '#000000', + 'buttonBackground': '#ffffff', + 'buttonForeground': '#000000', + 'buttonText': None, + 'dismissible': False, + 'expire': 60, + 'foreground': '#ffffff', + 'icon': None, + 'sound': None, + 'text': None, + 'visible': True, + }), + ), + dict({ + }), + ) +# --- diff --git a/tests/components/kiosker/test_services.py b/tests/components/kiosker/test_services.py new file mode 100644 index 000000000000..0b961a340738 --- /dev/null +++ b/tests/components/kiosker/test_services.py @@ -0,0 +1,261 @@ +"""Test the Kiosker services.""" + +from typing import Any +from unittest.mock import MagicMock, patch + +from kiosker import ( + AuthenticationError, + BadRequestError, + Blackout, + ConnectionError, + IPAuthenticationError, + ScreensaverState, + TLSVerificationError, +) +import pytest +from syrupy.assertion import SnapshotAssertion +import voluptuous as vol + +from homeassistant.components.kiosker.const import ( + ATTR_BACKGROUND, + ATTR_BUTTON_BACKGROUND, + ATTR_BUTTON_FOREGROUND, + ATTR_BUTTON_TEXT, + ATTR_DISMISSIBLE, + ATTR_EXPIRE, + ATTR_FOREGROUND, + ATTR_SOUND, + ATTR_TEXT, + ATTR_URL, + ATTR_VISIBLE, + DOMAIN, +) +from homeassistant.const import ATTR_DEVICE_ID, ATTR_ICON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + +KIOSKER_DEVICE_ID = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + + +async def _setup( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + mock_kiosker_api.screensaver_get_state.return_value = ScreensaverState( + visible=True, disabled=False + ) + mock_kiosker_api.blackout_get.return_value = Blackout(visible=False) + with patch("homeassistant.components.kiosker._PLATFORMS", [Platform.BUTTON]): + await setup_integration(hass, mock_config_entry) + + +async def test_navigate_url( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test navigate_url service calls the API with the correct URL.""" + await _setup(hass, mock_kiosker_api, mock_config_entry) + + device = device_registry.async_get_device(identifiers={(DOMAIN, KIOSKER_DEVICE_ID)}) + assert device is not None + + await hass.services.async_call( + DOMAIN, + "navigate_url", + {ATTR_DEVICE_ID: device.id, ATTR_URL: "https://example.com"}, + blocking=True, + ) + + assert mock_kiosker_api.navigate_url.call_args == snapshot + + +@pytest.mark.parametrize( + "service_data", + [ + pytest.param( + { + ATTR_VISIBLE: True, + ATTR_TEXT: "Hello World", + ATTR_BACKGROUND: [0, 0, 0], + ATTR_FOREGROUND: [255, 255, 255], + ATTR_ICON: "star", + ATTR_EXPIRE: 30, + ATTR_DISMISSIBLE: True, + ATTR_BUTTON_BACKGROUND: [255, 0, 0], + ATTR_BUTTON_FOREGROUND: [0, 255, 0], + ATTR_BUTTON_TEXT: "Dismiss", + ATTR_SOUND: "1007", + }, + id="all_fields", + ), + pytest.param( + {}, + id="defaults", + ), + ], +) +async def test_set_blackout( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + service_data: dict[str, Any], +) -> None: + """Test set_blackout service builds the correct Blackout object.""" + await _setup(hass, mock_kiosker_api, mock_config_entry) + + device = device_registry.async_get_device(identifiers={(DOMAIN, KIOSKER_DEVICE_ID)}) + assert device is not None + + await hass.services.async_call( + DOMAIN, + "set_blackout", + {ATTR_DEVICE_ID: device.id, **service_data}, + blocking=True, + ) + + assert mock_kiosker_api.blackout_set.call_args == snapshot + + +async def test_service_entry_not_loaded( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test services raise HomeAssistantError when the config entry is not loaded.""" + await _setup(hass, mock_kiosker_api, mock_config_entry) + + device = device_registry.async_get_device(identifiers={(DOMAIN, KIOSKER_DEVICE_ID)}) + assert device is not None + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + + with pytest.raises(HomeAssistantError, match="is not loaded"): + await hass.services.async_call( + DOMAIN, + "navigate_url", + {ATTR_DEVICE_ID: device.id, ATTR_URL: "https://example.com"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("service", "extra_data"), + [ + pytest.param( + "navigate_url", + {}, + id="navigate_url_missing_url", + ), + pytest.param( + "set_blackout", + {ATTR_BACKGROUND: [0, 0]}, + id="set_blackout_rgb_too_short", + ), + pytest.param( + "set_blackout", + {ATTR_BACKGROUND: [0, 0, 300]}, + id="set_blackout_rgb_out_of_range", + ), + pytest.param( + "set_blackout", + {ATTR_EXPIRE: "not_a_number"}, + id="set_blackout_expire_non_integer", + ), + ], +) +async def test_schema_rejects_invalid_input( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + service: str, + extra_data: dict[str, Any], +) -> None: + """Test that invalid service data is rejected by schema validation.""" + await _setup(hass, mock_kiosker_api, mock_config_entry) + + device = device_registry.async_get_device(identifiers={(DOMAIN, KIOSKER_DEVICE_ID)}) + assert device is not None + + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, + service, + {ATTR_DEVICE_ID: device.id, **extra_data}, + blocking=True, + ) + + mock_kiosker_api.navigate_url.assert_not_called() + mock_kiosker_api.blackout_set.assert_not_called() + + +@pytest.mark.parametrize( + ("exception", "expected"), + [ + pytest.param(ConnectionError, HomeAssistantError, id="connection_error"), + pytest.param(AuthenticationError, ServiceValidationError, id="auth_error"), + pytest.param(IPAuthenticationError, ServiceValidationError, id="ip_auth_error"), + pytest.param(TLSVerificationError, ServiceValidationError, id="tls_error"), + pytest.param(BadRequestError, ServiceValidationError, id="bad_request"), + ], +) +async def test_api_errors_are_wrapped( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + exception: type[Exception], + expected: type[Exception], +) -> None: + """Test that kiosker API exceptions are translated to HA exceptions.""" + await _setup(hass, mock_kiosker_api, mock_config_entry) + + device = device_registry.async_get_device(identifiers={(DOMAIN, KIOSKER_DEVICE_ID)}) + assert device is not None + + mock_kiosker_api.navigate_url.side_effect = exception + + with pytest.raises(expected): + await hass.services.async_call( + DOMAIN, + "navigate_url", + {ATTR_DEVICE_ID: device.id, ATTR_URL: "https://example.com"}, + blocking=True, + ) + + +async def test_service_non_kiosker_device( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test services raise ServiceValidationError when targeting a non-kiosker device.""" + await _setup(hass, mock_kiosker_api, mock_config_entry) + + other_config_entry = MockConfigEntry(domain="other_domain") + other_config_entry.add_to_hass(hass) + other_device = device_registry.async_get_or_create( + config_entry_id=other_config_entry.entry_id, + identifiers={("other_domain", "other_device")}, + ) + + with pytest.raises(ServiceValidationError, match=f"No {DOMAIN} devices"): + await hass.services.async_call( + DOMAIN, + "navigate_url", + {ATTR_DEVICE_ID: other_device.id, ATTR_URL: "https://example.com"}, + blocking=True, + ) From 828ec639ddf4dd58a4b98719d1117bf493bb42bd Mon Sep 17 00:00:00 2001 From: Evan Severson <208220+eseverson@users.noreply.github.com> Date: Mon, 8 Jun 2026 06:43:58 -0700 Subject: [PATCH 009/404] Strip trailing slash from Jellyfin server URL (#173049) --- homeassistant/components/jellyfin/__init__.py | 10 +++++++ .../components/jellyfin/config_flow.py | 3 ++ tests/components/jellyfin/test_config_flow.py | 29 +++++++++++++++++++ tests/components/jellyfin/test_init.py | 29 +++++++++++++++++++ 4 files changed, 71 insertions(+) diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py index 796d3b298eea..6f9ed10d5bab 100644 --- a/homeassistant/components/jellyfin/__init__.py +++ b/homeassistant/components/jellyfin/__init__.py @@ -2,6 +2,7 @@ from typing import Any +from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr @@ -65,6 +66,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: JellyfinConfigEntry) -> return True +async def async_migrate_entry(hass: HomeAssistant, entry: JellyfinConfigEntry) -> bool: + """Migrate an old config entry.""" + if entry.version == 1 and entry.minor_version < 2: + new_data = {**entry.data, CONF_URL: entry.data[CONF_URL].rstrip("/")} + hass.config_entries.async_update_entry(entry, data=new_data, minor_version=2) + + return True + + async def async_unload_entry(hass: HomeAssistant, entry: JellyfinConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/jellyfin/config_flow.py b/homeassistant/components/jellyfin/config_flow.py index 2549e75093ec..3b265897be34 100644 --- a/homeassistant/components/jellyfin/config_flow.py +++ b/homeassistant/components/jellyfin/config_flow.py @@ -46,6 +46,7 @@ class JellyfinConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Jellyfin.""" VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize the Jellyfin config flow.""" @@ -58,6 +59,8 @@ class JellyfinConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: + user_input[CONF_URL] = user_input[CONF_URL].rstrip("/") + if self.client_device_id is None: self.client_device_id = _generate_client_device_id() diff --git a/tests/components/jellyfin/test_config_flow.py b/tests/components/jellyfin/test_config_flow.py index dfcfc18cd54e..e12e21679af6 100644 --- a/tests/components/jellyfin/test_config_flow.py +++ b/tests/components/jellyfin/test_config_flow.py @@ -59,6 +59,35 @@ async def test_form( assert len(mock_client.jellyfin.get_user_settings.mock_calls) == 1 +async def test_form_strips_trailing_slash_from_url( + hass: HomeAssistant, + mock_jellyfin: MagicMock, + mock_client: MagicMock, + mock_client_device_id: MagicMock, + mock_setup_entry: MagicMock, +) -> None: + """Test a trailing slash is stripped from the configured URL. + + A trailing slash would otherwise be joined into a double-slashed request + path (e.g. //system/info/public) that some Jellyfin versions reject. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={**USER_INPUT, CONF_URL: f"{TEST_URL}/"}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + # The persisted URL has no trailing slash... + assert result2["data"][CONF_URL] == TEST_URL + # ...and the connection was attempted against the normalized URL. + mock_client.auth.connect_to_address.assert_called_once_with(TEST_URL) + + async def test_form_cannot_connect( hass: HomeAssistant, mock_jellyfin: MagicMock, diff --git a/tests/components/jellyfin/test_init.py b/tests/components/jellyfin/test_init.py index 1af59737296b..f8e9869c4e01 100644 --- a/tests/components/jellyfin/test_init.py +++ b/tests/components/jellyfin/test_init.py @@ -4,11 +4,13 @@ from unittest.mock import MagicMock from homeassistant.components.jellyfin.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import async_load_json_fixture +from .const import TEST_PASSWORD, TEST_URL, TEST_USERNAME from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -75,6 +77,33 @@ async def test_load_unload_config_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +async def test_migrate_strips_trailing_slash_from_url( + hass: HomeAssistant, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test migrating an entry strips a trailing slash from the stored URL.""" + config_entry = MockConfigEntry( + title="Jellyfin", + domain=DOMAIN, + data={ + CONF_URL: f"{TEST_URL}/", + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + unique_id="USER-UUID", + version=1, + minor_version=1, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.minor_version == 2 + assert config_entry.data[CONF_URL] == TEST_URL + + async def test_device_remove_devices( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, From b8bdd2c47ced5c47ee82fdf89250000e9dc5e7b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Mon, 8 Jun 2026 15:45:03 +0200 Subject: [PATCH 010/404] Add new Aqvify integration (#172936) --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/aqvify/__init__.py | 28 +++ .../components/aqvify/config_flow.py | 61 +++++ homeassistant/components/aqvify/const.py | 3 + .../components/aqvify/coordinator.py | 92 +++++++ homeassistant/components/aqvify/entity.py | 35 +++ homeassistant/components/aqvify/icons.json | 12 + homeassistant/components/aqvify/manifest.json | 12 + .../components/aqvify/quality_scale.yaml | 69 ++++++ homeassistant/components/aqvify/sensor.py | 79 ++++++ homeassistant/components/aqvify/strings.json | 33 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + tests/components/aqvify/__init__.py | 12 + tests/components/aqvify/conftest.py | 111 +++++++++ .../aqvify/fixtures/default_account.json | 3 + .../aqvify/fixtures/default_device_data.json | 6 + .../aqvify/fixtures/default_devices.json | 10 + .../aqvify/snapshots/test_init.ambr | 63 +++++ .../aqvify/snapshots/test_sensor.ambr | 233 ++++++++++++++++++ tests/components/aqvify/test_config_flow.py | 119 +++++++++ tests/components/aqvify/test_init.py | 75 ++++++ tests/components/aqvify/test_sensor.py | 31 +++ 26 files changed, 1110 insertions(+) create mode 100644 homeassistant/components/aqvify/__init__.py create mode 100644 homeassistant/components/aqvify/config_flow.py create mode 100644 homeassistant/components/aqvify/const.py create mode 100644 homeassistant/components/aqvify/coordinator.py create mode 100644 homeassistant/components/aqvify/entity.py create mode 100644 homeassistant/components/aqvify/icons.json create mode 100644 homeassistant/components/aqvify/manifest.json create mode 100644 homeassistant/components/aqvify/quality_scale.yaml create mode 100644 homeassistant/components/aqvify/sensor.py create mode 100644 homeassistant/components/aqvify/strings.json create mode 100644 tests/components/aqvify/__init__.py create mode 100644 tests/components/aqvify/conftest.py create mode 100644 tests/components/aqvify/fixtures/default_account.json create mode 100644 tests/components/aqvify/fixtures/default_device_data.json create mode 100644 tests/components/aqvify/fixtures/default_devices.json create mode 100644 tests/components/aqvify/snapshots/test_init.ambr create mode 100644 tests/components/aqvify/snapshots/test_sensor.ambr create mode 100644 tests/components/aqvify/test_config_flow.py create mode 100644 tests/components/aqvify/test_init.py create mode 100644 tests/components/aqvify/test_sensor.py diff --git a/.strict-typing b/.strict-typing index 2ab3c10bb012..bc9c41b0350e 100644 --- a/.strict-typing +++ b/.strict-typing @@ -96,6 +96,7 @@ homeassistant.components.aprs.* homeassistant.components.apsystems.* homeassistant.components.aqualogic.* homeassistant.components.aquostv.* +homeassistant.components.aqvify.* homeassistant.components.aranet.* homeassistant.components.arcam_fmj.* homeassistant.components.arris_tg2492lg.* diff --git a/CODEOWNERS b/CODEOWNERS index 373d4bfa7a16..714be2927bf7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -162,6 +162,8 @@ CLAUDE.md @home-assistant/core /tests/components/apsystems/ @mawoka-myblock @SonnenladenGmbH /homeassistant/components/aquacell/ @Jordi1990 /tests/components/aquacell/ @Jordi1990 +/homeassistant/components/aqvify/ @astrandb +/tests/components/aqvify/ @astrandb /homeassistant/components/aranet/ @aschmitz @thecode @anrijs /tests/components/aranet/ @aschmitz @thecode @anrijs /homeassistant/components/arcam_fmj/ @elupus diff --git a/homeassistant/components/aqvify/__init__.py b/homeassistant/components/aqvify/__init__.py new file mode 100644 index 000000000000..4dfbc30eef0d --- /dev/null +++ b/homeassistant/components/aqvify/__init__.py @@ -0,0 +1,28 @@ +"""The Aqvify integration.""" + +import logging + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import AqvifyConfigEntry, AqvifyCoordinator + +_LOGGER = logging.getLogger(__name__) +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: AqvifyConfigEntry) -> bool: + """Set up Aqvify from a config entry.""" + + coordinator = AqvifyCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: AqvifyConfigEntry) -> bool: + """Unload Aqvify config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/aqvify/config_flow.py b/homeassistant/components/aqvify/config_flow.py new file mode 100644 index 000000000000..3ba764f0328c --- /dev/null +++ b/homeassistant/components/aqvify/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for the Aqvify integration.""" + +import logging +from typing import Any + +from aiohttp import ClientResponseError +from pyaqvify import AqvifyAPI, AqvifyAuthException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } +) + + +class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Aqvify.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + hub = AqvifyAPI( + user_input[CONF_API_KEY], + websession=async_get_clientsession(self.hass), + ) + try: + account_data = await hub.async_get_account_id() + except AqvifyAuthException: + errors["base"] = "invalid_auth" + except ClientResponseError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(account_data.account_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title="Aqvify", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + description_placeholders={ + "aqvify_url": "https://app.aqvify.com/User", + }, + ) diff --git a/homeassistant/components/aqvify/const.py b/homeassistant/components/aqvify/const.py new file mode 100644 index 000000000000..45003e65ecbf --- /dev/null +++ b/homeassistant/components/aqvify/const.py @@ -0,0 +1,3 @@ +"""Constants for the Aqvify integration.""" + +DOMAIN = "aqvify" diff --git a/homeassistant/components/aqvify/coordinator.py b/homeassistant/components/aqvify/coordinator.py new file mode 100644 index 000000000000..858475aad180 --- /dev/null +++ b/homeassistant/components/aqvify/coordinator.py @@ -0,0 +1,92 @@ +"""Coordinator for Aqvify integration.""" + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from aiohttp import ClientResponseError +from pyaqvify import AqvifyAPI, AqvifyAuthException, AqvifyDeviceData, AqvifyDevices + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = timedelta(seconds=60) + +type AqvifyConfigEntry = ConfigEntry[AqvifyCoordinator] + + +@dataclass +class AqvifyCoordinatorData: + """Data class for storing coordinator data.""" + + devices: AqvifyDevices + device_data: dict[str, AqvifyDeviceData] + + +class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]): + """Data update coordinator for Aqvify devices.""" + + config_entry: AqvifyConfigEntry + + def __init__(self, hass: HomeAssistant, entry: AqvifyConfigEntry) -> None: + """Initialize the Aqvify data update coordinator.""" + super().__init__( + hass, + logger=_LOGGER, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + config_entry=entry, + ) + + self.api_client = AqvifyAPI( + entry.data[CONF_API_KEY], websession=async_get_clientsession(hass) + ) + + async def _async_setup(self) -> None: + """Set up the coordinator.""" + try: + await self.api_client.async_get_account_id() + except AqvifyAuthException as err: + raise ConfigEntryAuthFailed(f"Invalid Aqvify API key: {err}") from err + except (ClientResponseError, TimeoutError) as err: + raise ConfigEntryNotReady( + f"Failed to connect to Aqvify API: {err}" + ) from err + + async def _async_update_data(self) -> AqvifyCoordinatorData: + """Fetch device state.""" + try: + devices = await self.api_client.async_get_devices() + except ClientResponseError as err: + raise UpdateFailed(f"Error communicating with Aqvify API: {err}") from err + except TimeoutError as err: + raise UpdateFailed(f"Timeout communicating with Aqvify API: {err}") from err + + device_data = {} + for device in devices.devices.values(): + try: + device_key = str(device.device_key) + device_data[ + device_key + ] = await self.api_client.async_get_device_latest_data(device_key) + except ClientResponseError as err: + raise UpdateFailed( + f"Error communicating with Aqvify API: {err}" + ) from err + except TimeoutError as err: + raise UpdateFailed( + f"Timeout communicating with Aqvify API: {err}" + ) from err + + return AqvifyCoordinatorData( + devices=devices, + device_data=device_data, + ) diff --git a/homeassistant/components/aqvify/entity.py b/homeassistant/components/aqvify/entity.py new file mode 100644 index 000000000000..2f893f7de7f0 --- /dev/null +++ b/homeassistant/components/aqvify/entity.py @@ -0,0 +1,35 @@ +"""Defines a base Aqvify entity.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AqvifyCoordinator + + +class AqvifyBaseEntity(CoordinatorEntity[AqvifyCoordinator]): + """Defines a base Aqvify entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AqvifyCoordinator, + description: EntityDescription, + device_key: str, + ) -> None: + """Initialize the Aqvify entity.""" + super().__init__(coordinator) + + account_id = self.coordinator.config_entry.unique_id + self.device_key = device_key + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{account_id}_{device_key}")}, + name=coordinator.data.devices.devices[device_key].name, + manufacturer="Aqvify", + configuration_url="https://app.aqvify.com", + serial_number=device_key, + ) + self._attr_unique_id = f"{account_id}_{device_key}_{description.key}" + self.entity_description = description diff --git a/homeassistant/components/aqvify/icons.json b/homeassistant/components/aqvify/icons.json new file mode 100644 index 000000000000..96ff49e00115 --- /dev/null +++ b/homeassistant/components/aqvify/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "meter_value": { + "default": "mdi:waves-arrow-up" + }, + "water_level": { + "default": "mdi:waves" + } + } + } +} diff --git a/homeassistant/components/aqvify/manifest.json b/homeassistant/components/aqvify/manifest.json new file mode 100644 index 000000000000..fadc9f9e1e82 --- /dev/null +++ b/homeassistant/components/aqvify/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "aqvify", + "name": "Aqvify", + "codeowners": ["@astrandb"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/aqvify", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["pyaqvify"], + "quality_scale": "bronze", + "requirements": ["pyaqvify==0.0.8"] +} diff --git a/homeassistant/components/aqvify/quality_scale.yaml b/homeassistant/components/aqvify/quality_scale.yaml new file mode 100644 index 000000000000..1c294e6b795c --- /dev/null +++ b/homeassistant/components/aqvify/quality_scale.yaml @@ -0,0 +1,69 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No actions in this integration. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration does not provide any actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration do not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: todo + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/aqvify/sensor.py b/homeassistant/components/aqvify/sensor.py new file mode 100644 index 000000000000..e20ebb5f989f --- /dev/null +++ b/homeassistant/components/aqvify/sensor.py @@ -0,0 +1,79 @@ +"""Sensor platform for Aqvify integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from pyaqvify import AqvifyDeviceData + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, +) +from homeassistant.const import UnitOfLength +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import AqvifyConfigEntry +from .entity import AqvifyBaseEntity + +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class AqvifySensorEntityDescription(SensorEntityDescription): + """Description of an Aqvify sensor entity.""" + + value_fn: Callable[[AqvifyDeviceData], float | int | None] + + +ENTITIES: tuple[AqvifySensorEntityDescription, ...] = ( + AqvifySensorEntityDescription( + key="meter_value", + translation_key="meter_value", + native_unit_of_measurement=UnitOfLength.METERS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=2, + value_fn=lambda value: value.meter_value, + ), + AqvifySensorEntityDescription( + key="water_level", + translation_key="water_level", + native_unit_of_measurement=UnitOfLength.METERS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=2, + value_fn=lambda value: value.water_level, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AqvifyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Aqvify sensor entities from a config entry.""" + async_add_entities( + AqvifySensor(entry.runtime_data, description, device_key) + for description in ENTITIES + for device_key in entry.runtime_data.data.devices.devices + ) + + +class AqvifySensor(AqvifyBaseEntity, SensorEntity): + """Representation of an Aqvify sensor entity.""" + + entity_description: AqvifySensorEntityDescription + + @property + def native_value(self) -> StateType | datetime | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn( + self.coordinator.data.device_data[self.device_key] + ) diff --git a/homeassistant/components/aqvify/strings.json b/homeassistant/components/aqvify/strings.json new file mode 100644 index 000000000000..d04db9ef205d --- /dev/null +++ b/homeassistant/components/aqvify/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_key": "API key" + }, + "data_description": { + "api_key": "Your Aqvify API key" + }, + "description": "Navigate to your [Aqvify account]({aqvify_url}), copy your API key, and paste it below." + } + } + }, + "entity": { + "sensor": { + "meter_value": { + "name": "Meter value" + }, + "water_level": { + "name": "Water level" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2495b990e344..ecdd41d6e027 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -74,6 +74,7 @@ FLOWS = { "aprilaire", "apsystems", "aquacell", + "aqvify", "aranet", "arcam_fmj", "arve", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d9c8e68df6b1..4e3b7ba8ceee 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -520,6 +520,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "aqvify": { + "name": "Aqvify", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "aranet": { "name": "Aranet", "integration_type": "device", diff --git a/mypy.ini b/mypy.ini index 2fae3bcc1da4..e01d35c15687 100644 --- a/mypy.ini +++ b/mypy.ini @@ -716,6 +716,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.aqvify.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.aranet.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 25c150483315..03253c21777f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2017,6 +2017,9 @@ pyanglianwater==3.1.2 # homeassistant.components.aprilaire pyaprilaire==0.9.1 +# homeassistant.components.aqvify +pyaqvify==0.0.8 + # homeassistant.components.atag pyatag==0.3.5.3 diff --git a/tests/components/aqvify/__init__.py b/tests/components/aqvify/__init__.py new file mode 100644 index 000000000000..7cb8a469788f --- /dev/null +++ b/tests/components/aqvify/__init__.py @@ -0,0 +1,12 @@ +"""Tests for Aqvify integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Helper for setting up the component.""" + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/aqvify/conftest.py b/tests/components/aqvify/conftest.py new file mode 100644 index 000000000000..121abf5490b0 --- /dev/null +++ b/tests/components/aqvify/conftest.py @@ -0,0 +1,111 @@ +"""Common fixtures for the Aqvify tests.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from pyaqvify import AqvifyAccount, AqvifyDeviceData, AqvifyDevices +import pytest + +from homeassistant.components.aqvify.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import ( + MockConfigEntry, + async_load_json_array_fixture, + async_load_json_object_fixture, +) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.aqvify.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + config_entry = MockConfigEntry( + minor_version=1, + domain=DOMAIN, + title="Aqvify test", + data={"api_key": "fake_api_key"}, + entry_id="aqvify_test", + unique_id="test_account_id", + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture +def mock_aqvify_client( + device_fixture: list[dict[str, Any]], + device_data_fixture: dict[str, Any], + account_fixture: dict[str, Any], +) -> Generator[MagicMock]: + """Mock an Aqvify client.""" + + with ( + patch( + "homeassistant.components.aqvify.coordinator.AqvifyAPI", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.aqvify.config_flow.AqvifyAPI", + new=mock_client, + ), + ): + client = mock_client.return_value + + client.async_get_account_id.return_value = AqvifyAccount(account_fixture) + client.async_get_devices.return_value = AqvifyDevices(device_fixture) + client.async_get_device_latest_data.return_value = AqvifyDeviceData( + device_data_fixture + ) + yield client + + +@pytest.fixture(scope="package") +def load_device_file() -> str: + """Fixture for loading device file.""" + return "default_devices.json" + + +@pytest.fixture(scope="package") +def load_device_data_file() -> str: + """Fixture for loading device data file.""" + return "default_device_data.json" + + +@pytest.fixture(scope="package") +def load_account_file() -> str: + """Fixture for loading account file.""" + return "default_account.json" + + +@pytest.fixture +async def device_fixture( + hass: HomeAssistant, load_device_file: str +) -> list[dict[str, Any]]: + """Fixture for device.""" + return await async_load_json_array_fixture(hass, load_device_file, DOMAIN) + + +@pytest.fixture +async def device_data_fixture( + hass: HomeAssistant, load_device_data_file: str +) -> dict[str, Any]: + """Fixture for device data.""" + return await async_load_json_object_fixture(hass, load_device_data_file, DOMAIN) + + +@pytest.fixture +async def account_fixture( + hass: HomeAssistant, load_account_file: str +) -> dict[str, Any]: + """Fixture for account data.""" + return await async_load_json_object_fixture(hass, load_account_file, DOMAIN) diff --git a/tests/components/aqvify/fixtures/default_account.json b/tests/components/aqvify/fixtures/default_account.json new file mode 100644 index 000000000000..98531af9528c --- /dev/null +++ b/tests/components/aqvify/fixtures/default_account.json @@ -0,0 +1,3 @@ +{ + "accountId": "test_account_id" +} diff --git a/tests/components/aqvify/fixtures/default_device_data.json b/tests/components/aqvify/fixtures/default_device_data.json new file mode 100644 index 000000000000..bb632cba8059 --- /dev/null +++ b/tests/components/aqvify/fixtures/default_device_data.json @@ -0,0 +1,6 @@ +{ + "dateTime": "2026-06-04T09:36:06+00:00", + "waterLevel": -0.136786005, + "meterValue": 0.823213995, + "status": null +} diff --git a/tests/components/aqvify/fixtures/default_devices.json b/tests/components/aqvify/fixtures/default_devices.json new file mode 100644 index 000000000000..ca4aab9a4d4e --- /dev/null +++ b/tests/components/aqvify/fixtures/default_devices.json @@ -0,0 +1,10 @@ +[ + { + "deviceKey": "DeviceKey_1", + "name": "Device 1" + }, + { + "deviceKey": "DeviceKey_2", + "name": "Device 2" + } +] diff --git a/tests/components/aqvify/snapshots/test_init.ambr b/tests/components/aqvify/snapshots/test_init.ambr new file mode 100644 index 000000000000..fe3d2c055820 --- /dev/null +++ b/tests/components/aqvify/snapshots/test_init.ambr @@ -0,0 +1,63 @@ +# serializer version: 1 +# name: test_device_registry_integration + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://app.aqvify.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'aqvify', + 'test_account_id_DeviceKey_1', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Aqvify', + 'model': None, + 'model_id': None, + 'name': 'Device 1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'DeviceKey_1', + 'sw_version': None, + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://app.aqvify.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'aqvify', + 'test_account_id_DeviceKey_2', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Aqvify', + 'model': None, + 'model_id': None, + 'name': 'Device 2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'DeviceKey_2', + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- diff --git a/tests/components/aqvify/snapshots/test_sensor.ambr b/tests/components/aqvify/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..54dcadfdbf2c --- /dev/null +++ b/tests/components/aqvify/snapshots/test_sensor.ambr @@ -0,0 +1,233 @@ +# serializer version: 1 +# name: test_sensor_snapshot[sensor.device_1_meter_value-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.device_1_meter_value', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Meter value', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Meter value', + 'platform': 'aqvify', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'meter_value', + 'unique_id': 'test_account_id_DeviceKey_1_meter_value', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.device_1_meter_value-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Device 1 Meter value', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_1_meter_value', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.823213995', + }) +# --- +# name: test_sensor_snapshot[sensor.device_1_water_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.device_1_water_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Water level', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water level', + 'platform': 'aqvify', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_level', + 'unique_id': 'test_account_id_DeviceKey_1_water_level', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.device_1_water_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Device 1 Water level', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_1_water_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-0.136786005', + }) +# --- +# name: test_sensor_snapshot[sensor.device_2_meter_value-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.device_2_meter_value', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Meter value', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Meter value', + 'platform': 'aqvify', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'meter_value', + 'unique_id': 'test_account_id_DeviceKey_2_meter_value', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.device_2_meter_value-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Device 2 Meter value', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_2_meter_value', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.823213995', + }) +# --- +# name: test_sensor_snapshot[sensor.device_2_water_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.device_2_water_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Water level', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water level', + 'platform': 'aqvify', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_level', + 'unique_id': 'test_account_id_DeviceKey_2_water_level', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.device_2_water_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Device 2 Water level', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_2_water_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-0.136786005', + }) +# --- diff --git a/tests/components/aqvify/test_config_flow.py b/tests/components/aqvify/test_config_flow.py new file mode 100644 index 000000000000..6c752c128be2 --- /dev/null +++ b/tests/components/aqvify/test_config_flow.py @@ -0,0 +1,119 @@ +"""Test the Aqvify config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from aiohttp import ClientResponseError +from pyaqvify import AqvifyAuthException +import pytest + +from homeassistant.components.aqvify.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_full_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_aqvify_client: MagicMock +) -> None: + """Test full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "test-api-key", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Aqvify" + assert result["data"] == { + CONF_API_KEY: "test-api-key", + } + assert result["result"].unique_id == "test_account_id" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_base"), + [ + (AqvifyAuthException, "invalid_auth"), + ( + ClientResponseError(request_info=None, history=None, status=500), + "cannot_connect", + ), + (TypeError, "unknown"), + ], +) +async def test_form_invalid( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_aqvify_client: MagicMock, + side_effect: Exception, + error_base: str, +) -> None: + """Test we handle errors during form submission.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_aqvify_client.async_get_account_id.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "test-api-key", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_base} + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + mock_aqvify_client.async_get_account_id.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "test-api-key", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Aqvify" + assert result["data"] == { + CONF_API_KEY: "test-api-key", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_same_account_setup( + hass: HomeAssistant, mock_config_entry: AsyncMock, mock_aqvify_client: MagicMock +) -> None: + """Test setup same account twice.""" + + # Create an existing config entry for the same user account + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "test-api-key2", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/aqvify/test_init.py b/tests/components/aqvify/test_init.py new file mode 100644 index 000000000000..99bba4e0134d --- /dev/null +++ b/tests/components/aqvify/test_init.py @@ -0,0 +1,75 @@ +"""Test the Aqvify init.""" + +from unittest.mock import MagicMock + +from pyaqvify import AqvifyAuthException +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +import homeassistant.helpers.device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_aqvify_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + entry = mock_config_entry + + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("error", "expected_state"), + [ + (None, ConfigEntryState.LOADED), + (AqvifyAuthException, ConfigEntryState.SETUP_ERROR), + (TimeoutError, ConfigEntryState.SETUP_RETRY), + ], + ids=["no_error", "auth_error", "timeout_error"], +) +async def test_setup_entry_with_error( + hass: HomeAssistant, + mock_aqvify_client: MagicMock, + mock_config_entry: MockConfigEntry, + error: Exception | None, + expected_state: ConfigEntryState, +) -> None: + """Test setup entry with error.""" + mock_aqvify_client.async_get_account_id.side_effect = error + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is expected_state + + +async def test_device_registry_integration( + hass: HomeAssistant, + mock_aqvify_client: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device registry integration creates correct devices.""" + await setup_integration(hass, mock_config_entry) + + # Get all devices created for this config entry + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + # Snapshot the devices to ensure they have the correct structure + assert device_entries == snapshot diff --git a/tests/components/aqvify/test_sensor.py b/tests/components/aqvify/test_sensor.py new file mode 100644 index 000000000000..c046ad6e11c0 --- /dev/null +++ b/tests/components/aqvify/test_sensor.py @@ -0,0 +1,31 @@ +"""Test Aqvify sensor platform.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_snapshot( + hass: HomeAssistant, + mock_aqvify_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test sensor setup for cloud connection.""" + with patch("homeassistant.components.aqvify.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) From d4accebb3b77f8f3e70511f59b6b2ce76088b090 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:58:54 +0200 Subject: [PATCH 011/404] Use DOMAIN constant in test (async_setup_component a-g) (#173013) --- .../air_quality/test_air_quality.py | 11 +- tests/components/alexa/test_init.py | 4 +- tests/components/analytics/test_analytics.py | 7 +- tests/components/anthropic/conftest.py | 2 +- tests/components/anthropic/test_init.py | 4 +- tests/components/api/test_init.py | 7 +- tests/components/assist_pipeline/conftest.py | 2 +- .../assist_pipeline/test_pipeline.py | 20 ++-- tests/components/auth/__init__.py | 3 +- tests/components/auth/test_init.py | 19 +-- tests/components/auth/test_mfa_setup_flow.py | 3 +- tests/components/automation/test_blueprint.py | 4 +- tests/components/automation/test_init.py | 10 +- tests/components/automation/test_logbook.py | 3 +- tests/components/aws/test_init.py | 21 ++-- tests/components/backup/test_onboarding.py | 13 +- tests/components/blebox/test_config_flow.py | 2 +- .../blueprint/test_websocket_api.py | 3 +- tests/components/camera/conftest.py | 6 +- tests/components/camera/test_init.py | 20 ++-- tests/components/camera/test_webrtc.py | 11 +- tests/components/cast/test_media_player.py | 2 +- tests/components/cloud/test_binary_sensor.py | 3 +- tests/components/cloud/test_client.py | 16 +-- tests/components/cloud/test_init.py | 10 +- tests/components/config/test_automation.py | 18 +-- .../components/config/test_config_entries.py | 44 +++---- tests/components/config/test_core.py | 10 +- .../components/config/test_device_registry.py | 8 +- tests/components/config/test_init.py | 3 +- tests/components/config/test_scene.py | 12 +- tests/components/config/test_script.py | 18 +-- tests/components/conversation/conftest.py | 4 +- .../conversation/test_default_agent.py | 11 +- .../test_default_agent_intents.py | 3 +- tests/components/conversation/test_entity.py | 3 +- tests/components/conversation/test_init.py | 5 +- tests/components/conversation/test_trace.py | 4 +- .../components/device_automation/test_init.py | 45 +++---- tests/components/device_tracker/test_init.py | 3 +- tests/components/eafm/test_sensor.py | 2 +- tests/components/emoncms_history/test_init.py | 7 +- tests/components/emulated_hue/test_init.py | 3 +- tests/components/energy/conftest.py | 4 +- tests/components/energy/test_sensor.py | 42 +++---- tests/components/energy/test_websocket_api.py | 4 +- tests/components/esphome/test_ffmpeg_proxy.py | 3 +- tests/components/evil_genius_labs/conftest.py | 2 +- tests/components/fan/test_init.py | 2 +- tests/components/file_upload/test_init.py | 9 +- tests/components/forecast_solar/test_init.py | 2 +- tests/components/frontend/test_init.py | 14 +-- tests/components/frontend/test_storage.py | 2 +- .../google_assistant/test_google_assistant.py | 3 +- .../components/google_assistant/test_init.py | 3 +- .../conftest.py | 4 +- tests/components/group/test_init.py | 111 +++++++++--------- tests/components/group/test_light.py | 4 +- tests/components/group/test_lock.py | 4 +- tests/components/group/test_notify.py | 6 +- tests/components/group/test_switch.py | 4 +- 61 files changed, 329 insertions(+), 303 deletions(-) diff --git a/tests/components/air_quality/test_air_quality.py b/tests/components/air_quality/test_air_quality.py index 7bc21dee03cb..ef8c526aec86 100644 --- a/tests/components/air_quality/test_air_quality.py +++ b/tests/components/air_quality/test_air_quality.py @@ -2,7 +2,12 @@ import pytest -from homeassistant.components.air_quality import ATTR_N2O, ATTR_OZONE, ATTR_PM_10 +from homeassistant.components.air_quality import ( + ATTR_N2O, + ATTR_OZONE, + ATTR_PM_10, + DOMAIN, +) from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_UNIT_OF_MEASUREMENT, @@ -22,7 +27,7 @@ async def test_state(hass: HomeAssistant) -> None: """Test Air Quality state.""" config = {"air_quality": {"platform": "demo"}} - assert await async_setup_component(hass, "air_quality", config) + assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() state = hass.states.get("air_quality.demo_air_quality_home") @@ -35,7 +40,7 @@ async def test_attributes(hass: HomeAssistant) -> None: """Test Air Quality attributes.""" config = {"air_quality": {"platform": "demo"}} - assert await async_setup_component(hass, "air_quality", config) + assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() state = hass.states.get("air_quality.demo_air_quality_office") diff --git a/tests/components/alexa/test_init.py b/tests/components/alexa/test_init.py index 3c6c54b7c760..973516ea876c 100644 --- a/tests/components/alexa/test_init.py +++ b/tests/components/alexa/test_init.py @@ -1,6 +1,6 @@ """Tests for alexa.""" -from homeassistant.components.alexa.const import EVENT_ALEXA_SMART_HOME +from homeassistant.components.alexa.const import DOMAIN, EVENT_ALEXA_SMART_HOME from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -10,7 +10,7 @@ from tests.components.logbook.common import MockRow, mock_humanify async def test_humanify_alexa_event(hass: HomeAssistant) -> None: """Test humanifying Alexa event.""" hass.config.components.add("recorder") - await async_setup_component(hass, "alexa", {}) + await async_setup_component(hass, DOMAIN, {}) await async_setup_component(hass, "logbook", {}) await hass.async_block_till_done() hass.states.async_set("light.kitchen", "on", {"friendly_name": "Kitchen Light"}) diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 0a2b42d008e8..f5318434b72e 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -28,6 +28,7 @@ from homeassistant.components.analytics.const import ( ATTR_USAGE, BASIC_ENDPOINT_URL, BASIC_ENDPOINT_URL_DEV, + DOMAIN, SNAPSHOT_DEFAULT_URL, SNAPSHOT_URL_PATH, ) @@ -1033,7 +1034,7 @@ async def test_devices_payload_no_entities( device_registry: dr.DeviceRegistry, ) -> None: """Test devices payload with no entities.""" - assert await async_setup_component(hass, "analytics", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert await async_devices_payload(hass) == { "version": "home-assistant:1", "home_assistant": MOCK_VERSION, @@ -1176,7 +1177,7 @@ async def test_devices_payload_with_entities( entity_registry: er.EntityRegistry, ) -> None: """Test devices payload with entities.""" - assert await async_setup_component(hass, "analytics", {}) + assert await async_setup_component(hass, DOMAIN, {}) mock_config_entry = MockConfigEntry(domain="hue") mock_config_entry.add_to_hass(hass) @@ -1370,7 +1371,7 @@ async def test_analytics_platforms( entity_registry: er.EntityRegistry, ) -> None: """Test analytics platforms.""" - assert await async_setup_component(hass, "analytics", {}) + assert await async_setup_component(hass, DOMAIN, {}) mock_config_entry = MockConfigEntry(domain="test") mock_config_entry.add_to_hass(hass) diff --git a/tests/components/anthropic/conftest.py b/tests/components/anthropic/conftest.py index 0e6bd0d901d6..0909aac2da6d 100644 --- a/tests/components/anthropic/conftest.py +++ b/tests/components/anthropic/conftest.py @@ -89,7 +89,7 @@ async def mock_init_component( new_callable=AsyncMock, return_value=AsyncPage(data=model_list), ): - assert await async_setup_component(hass, "anthropic", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() yield diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index df2f1e9c7853..3c1505ff54f3 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -70,7 +70,7 @@ async def test_init_error( "anthropic.resources.models.AsyncModels.list", side_effect=side_effect, ): - assert await async_setup_component(hass, "anthropic", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert error in caplog.text @@ -90,7 +90,7 @@ async def test_init_auth_error( message="", ), ): - assert await async_setup_component(hass, "anthropic", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index ddf31392eb5d..df95342e9bcf 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -15,6 +15,7 @@ import voluptuous as vol from homeassistant import const, core as ha from homeassistant.auth.models import Credentials from homeassistant.bootstrap import DATA_LOGGING +from homeassistant.components.api import DOMAIN from homeassistant.components.group import DOMAIN as GROUP_DOMAIN from homeassistant.components.logger import DOMAIN as LOGGER_DOMAIN from homeassistant.components.system_health import DOMAIN as SYSTEM_HEALTH_DOMAIN @@ -32,7 +33,7 @@ async def mock_api_client( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> TestClient: """Start the Home Assistant HTTP component and return admin API client.""" - await async_setup_component(hass, "api", {}) + await async_setup_component(hass, DOMAIN, {}) return await hass_client() @@ -717,7 +718,7 @@ async def test_api_error_log( ) -> None: """Test if we can fetch the error log.""" hass.data[DATA_LOGGING] = "/some/path" - await async_setup_component(hass, "api", {}) + await async_setup_component(hass, DOMAIN, {}) client = await hass_client_no_auth() resp = await client.get(const.URL_API_ERROR_LOG) @@ -836,7 +837,7 @@ async def test_states_view_filters( """Test filtering only visible states.""" assert not hass_read_only_user.is_admin hass_read_only_user.mock_policy({"entities": {"entity_ids": {"test.entity": True}}}) - await async_setup_component(hass, "api", {}) + await async_setup_component(hass, DOMAIN, {}) read_only_user_credential = Credentials( id="mock-read-only-credential-id", auth_provider_type="homeassistant", diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index 40a0fca523c1..ccd5fb849486 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -309,7 +309,7 @@ async def init_supporting_components( async def init_components(hass: HomeAssistant, init_supporting_components): """Initialize relevant components with empty configs.""" - assert await async_setup_component(hass, "assist_pipeline", {}) + assert await async_setup_component(hass, DOMAIN, {}) @pytest.fixture diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 922b51e3f636..a9dc2bef202f 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -231,7 +231,7 @@ async def test_loading_pipelines_from_storage( }, } - assert await async_setup_component(hass, "assist_pipeline", {}) + assert await async_setup_component(hass, DOMAIN, {}) pipeline_data: PipelineData = hass.data[DOMAIN] store = pipeline_data.pipeline_store @@ -291,7 +291,7 @@ async def test_migrate_pipeline_store( }, } - assert await async_setup_component(hass, "assist_pipeline", {}) + assert await async_setup_component(hass, DOMAIN, {}) pipeline_data: PipelineData = hass.data[DOMAIN] store = pipeline_data.pipeline_store @@ -303,7 +303,7 @@ async def test_migrate_pipeline_store( @pytest.mark.usefixtures("disable_tts_entity") async def test_create_default_pipeline(hass: HomeAssistant) -> None: """Test async_create_default_pipeline.""" - assert await async_setup_component(hass, "assist_pipeline", {}) + assert await async_setup_component(hass, DOMAIN, {}) pipeline_data: PipelineData = hass.data[DOMAIN] store = pipeline_data.pipeline_store @@ -341,7 +341,7 @@ async def test_create_default_pipeline(hass: HomeAssistant) -> None: async def test_get_pipeline(hass: HomeAssistant) -> None: """Test async_get_pipeline.""" - assert await async_setup_component(hass, "assist_pipeline", {}) + assert await async_setup_component(hass, DOMAIN, {}) pipeline_data: PipelineData = hass.data[DOMAIN] store = pipeline_data.pipeline_store @@ -357,7 +357,7 @@ async def test_get_pipeline(hass: HomeAssistant) -> None: async def test_get_pipelines(hass: HomeAssistant) -> None: """Test async_get_pipelines.""" - assert await async_setup_component(hass, "assist_pipeline", {}) + assert await async_setup_component(hass, DOMAIN, {}) pipeline_data: PipelineData = hass.data[DOMAIN] store = pipeline_data.pipeline_store @@ -404,7 +404,7 @@ async def test_default_pipeline_no_stt_tts( """Test async_get_pipeline.""" hass.config.country = ha_country hass.config.language = ha_language - assert await async_setup_component(hass, "assist_pipeline", {}) + assert await async_setup_component(hass, DOMAIN, {}) pipeline_data: PipelineData = hass.data[DOMAIN] store = pipeline_data.pipeline_store @@ -468,7 +468,7 @@ async def test_default_pipeline( patch.object(mock_stt_provider_entity, "_supported_languages", MANY_LANGUAGES), patch.object(mock_tts_provider, "_supported_languages", MANY_LANGUAGES), ): - assert await async_setup_component(hass, "assist_pipeline", {}) + assert await async_setup_component(hass, DOMAIN, {}) pipeline_data: PipelineData = hass.data[DOMAIN] store = pipeline_data.pipeline_store @@ -499,7 +499,7 @@ async def test_default_pipeline_unsupported_stt_language( ) -> None: """Test async_get_pipeline.""" with patch.object(mock_stt_provider_entity, "_supported_languages", ["smurfish"]): - assert await async_setup_component(hass, "assist_pipeline", {}) + assert await async_setup_component(hass, DOMAIN, {}) pipeline_data: PipelineData = hass.data[DOMAIN] store = pipeline_data.pipeline_store @@ -530,7 +530,7 @@ async def test_default_pipeline_unsupported_tts_language( ) -> None: """Test async_get_pipeline.""" with patch.object(mock_tts_provider, "_supported_languages", ["smurfish"]): - assert await async_setup_component(hass, "assist_pipeline", {}) + assert await async_setup_component(hass, DOMAIN, {}) pipeline_data: PipelineData = hass.data[DOMAIN] store = pipeline_data.pipeline_store @@ -558,7 +558,7 @@ async def test_update_pipeline( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test async_update_pipeline.""" - assert await async_setup_component(hass, "assist_pipeline", {}) + assert await async_setup_component(hass, DOMAIN, {}) pipelines = async_get_pipelines(hass) pipelines = list(pipelines) diff --git a/tests/components/auth/__init__.py b/tests/components/auth/__init__.py index 7b48855493e6..d17e1e0a19c8 100644 --- a/tests/components/auth/__init__.py +++ b/tests/components/auth/__init__.py @@ -3,6 +3,7 @@ from typing import Any from homeassistant import auth +from homeassistant.components.auth import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.setup import async_setup_component @@ -39,7 +40,7 @@ async def async_setup_auth( EMPTY_CONFIG if module_configs is UNDEFINED else module_configs, ) ensure_auth_manager_loaded(hass.auth) - await async_setup_component(hass, "auth", {}) + await async_setup_component(hass, DOMAIN, {}) if setup_api: await async_setup_component(hass, "api", {}) if custom_ip: diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index ec6d364fee08..c9f2b1f0b9c8 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -16,6 +16,7 @@ from homeassistant.auth.models import ( RefreshToken, ) from homeassistant.components import auth +from homeassistant.components.auth import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -205,7 +206,7 @@ async def test_ws_current_user( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_access_token: str ) -> None: """Test the current user command with Home Assistant creds.""" - assert await async_setup_component(hass, "auth", {}) + assert await async_setup_component(hass, DOMAIN, {}) refresh_token = hass.auth.async_validate_access_token(hass_access_token) user = refresh_token.user @@ -430,7 +431,7 @@ async def test_ws_long_lived_access_token( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_access_token: str ) -> None: """Test generate long-lived access token.""" - assert await async_setup_component(hass, "auth", {"http": {}}) + assert await async_setup_component(hass, DOMAIN, {"http": {}}) ws_client = await hass_ws_client(hass, hass_access_token) @@ -460,7 +461,7 @@ async def test_ws_refresh_tokens( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_access_token: str ) -> None: """Test fetching refresh token metadata.""" - assert await async_setup_component(hass, "auth", {"http": {}}) + assert await async_setup_component(hass, DOMAIN, {"http": {}}) ws_client = await hass_ws_client(hass, hass_access_token) @@ -491,7 +492,7 @@ async def test_ws_delete_refresh_token( hass_access_token: str, ) -> None: """Test deleting a refresh token.""" - assert await async_setup_component(hass, "auth", {"http": {}}) + assert await async_setup_component(hass, DOMAIN, {"http": {}}) refresh_token = await hass.auth.async_create_refresh_token( hass_admin_user, CLIENT_ID, credential=hass_admin_credential @@ -523,7 +524,7 @@ async def test_ws_delete_all_refresh_tokens_error( caplog: pytest.LogCaptureFixture, ) -> None: """Test deleting all refresh tokens, where a revoke callback raises an error.""" - assert await async_setup_component(hass, "auth", {"http": {}}) + assert await async_setup_component(hass, DOMAIN, {"http": {}}) # one token already exists await hass.auth.async_create_refresh_token( @@ -605,7 +606,7 @@ async def test_ws_delete_all_refresh_tokens( expected_remaining_long_lived_tokens: int, ) -> None: """Test deleting all or some refresh tokens.""" - assert await async_setup_component(hass, "auth", {"http": {}}) + assert await async_setup_component(hass, DOMAIN, {"http": {}}) # one token already exists await hass.auth.async_create_refresh_token( @@ -669,7 +670,7 @@ async def test_ws_sign_path( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_access_token: str ) -> None: """Test signing a path.""" - assert await async_setup_component(hass, "auth", {"http": {}}) + assert await async_setup_component(hass, DOMAIN, {"http": {}}) ws_client = await hass_ws_client(hass, hass_access_token) with patch( @@ -701,7 +702,7 @@ async def test_ws_refresh_token_set_expiry( hass_access_token: str, ) -> None: """Test setting expiry of a refresh token.""" - assert await async_setup_component(hass, "auth", {"http": {}}) + assert await async_setup_component(hass, DOMAIN, {"http": {}}) refresh_token = await hass.auth.async_create_refresh_token( hass_admin_user, CLIENT_ID, credential=hass_admin_credential @@ -742,7 +743,7 @@ async def test_ws_refresh_token_set_expiry_error( hass_access_token: str, ) -> None: """Test setting expiry of a invalid refresh token returns error.""" - assert await async_setup_component(hass, "auth", {"http": {}}) + assert await async_setup_component(hass, DOMAIN, {"http": {}}) ws_client = await hass_ws_client(hass, hass_access_token) diff --git a/tests/components/auth/test_mfa_setup_flow.py b/tests/components/auth/test_mfa_setup_flow.py index c497a31d08aa..b6a465d8ed49 100644 --- a/tests/components/auth/test_mfa_setup_flow.py +++ b/tests/components/auth/test_mfa_setup_flow.py @@ -1,6 +1,7 @@ """Tests for the mfa setup flow.""" from homeassistant.auth import auth_manager_from_config +from homeassistant.components.auth import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.setup import async_setup_component @@ -36,7 +37,7 @@ async def test_ws_setup_depose_mfa( ], ) ensure_auth_manager_loaded(hass.auth) - await async_setup_component(hass, "auth", {"http": {}}) + await async_setup_component(hass, DOMAIN, {"http": {}}) user = MockUser(id="mock-user").add_to_hass(hass) cred = await hass.auth.auth_providers[0].async_get_or_create_credentials( diff --git a/tests/components/automation/test_blueprint.py b/tests/components/automation/test_blueprint.py index 1e3de5a1acf7..f899f93c0b44 100644 --- a/tests/components/automation/test_blueprint.py +++ b/tests/components/automation/test_blueprint.py @@ -82,7 +82,7 @@ async def test_notify_leaving_zone( ): assert await async_setup_component( hass, - "automation", + DOMAIN, { "automation": { "use_blueprint": { @@ -160,7 +160,7 @@ async def test_motion_light(hass: HomeAssistant) -> None: ): assert await async_setup_component( hass, - "automation", + DOMAIN, { "automation": { "use_blueprint": { diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 1bb78e06c95e..d360c02b64ad 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -2986,7 +2986,7 @@ async def test_blueprint_automation( """Test blueprint automation.""" assert await async_setup_component( hass, - "automation", + DOMAIN, { "automation": { "use_blueprint": { @@ -3021,7 +3021,7 @@ async def test_blueprint_automation_legacy_schema( """Test blueprint automation where the blueprint is using legacy schema.""" assert await async_setup_component( hass, - "automation", + DOMAIN, { "automation": { "use_blueprint": { @@ -3081,7 +3081,7 @@ async def test_blueprint_automation_override( """Test blueprint automation where the automation config overrides the blueprint.""" assert await async_setup_component( hass, - "automation", + DOMAIN, { "automation": { "use_blueprint": { @@ -3157,7 +3157,7 @@ async def test_blueprint_automation_bad_config( """Test blueprint automation with bad inputs.""" assert await async_setup_component( hass, - "automation", + DOMAIN, { "automation": { "use_blueprint": { @@ -3196,7 +3196,7 @@ async def test_blueprint_automation_fails_substitution( ): assert await async_setup_component( hass, - "automation", + DOMAIN, { "automation": { "use_blueprint": { diff --git a/tests/components/automation/test_logbook.py b/tests/components/automation/test_logbook.py index 4aa494ad5b71..821bcb28ca16 100644 --- a/tests/components/automation/test_logbook.py +++ b/tests/components/automation/test_logbook.py @@ -1,6 +1,7 @@ """Test automation logbook.""" from homeassistant.components import automation +from homeassistant.components.automation import DOMAIN from homeassistant.core import Context, HomeAssistant from homeassistant.setup import async_setup_component @@ -10,7 +11,7 @@ from tests.components.logbook.common import MockRow, mock_humanify async def test_humanify_automation_trigger_event(hass: HomeAssistant) -> None: """Test humanifying Shelly click event.""" hass.config.components.add("recorder") - assert await async_setup_component(hass, "automation", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert await async_setup_component(hass, "logbook", {}) await hass.async_block_till_done() context = Context() diff --git a/tests/components/aws/test_init.py b/tests/components/aws/test_init.py index 820b08e51b4b..142ee86981e3 100644 --- a/tests/components/aws/test_init.py +++ b/tests/components/aws/test_init.py @@ -4,6 +4,7 @@ import json from typing import Any from unittest.mock import AsyncMock, MagicMock, call, patch as async_patch +from homeassistant.components.aws import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -45,7 +46,7 @@ async def test_empty_config(hass: HomeAssistant) -> None: with async_patch( "homeassistant.components.aws.AioSession", return_value=mock_session ): - await async_setup_component(hass, "aws", {"aws": {}}) + await async_setup_component(hass, DOMAIN, {"aws": {}}) await hass.async_block_till_done() # we don't validate auto-created default profile @@ -60,7 +61,7 @@ async def test_empty_credential(hass: HomeAssistant) -> None: ): await async_setup_component( hass, - "aws", + DOMAIN, { "aws": { "notify": [ @@ -90,7 +91,7 @@ async def test_profile_credential(hass: HomeAssistant) -> None: ): await async_setup_component( hass, - "aws", + DOMAIN, { "aws": { "credentials": {"name": "test", "profile_name": "test-profile"}, @@ -125,7 +126,7 @@ async def test_access_key_credential(hass: HomeAssistant) -> None: ): await async_setup_component( hass, - "aws", + DOMAIN, { "aws": { "credentials": [ @@ -172,7 +173,7 @@ async def test_notify_credential(hass: HomeAssistant) -> None: ): await async_setup_component( hass, - "aws", + DOMAIN, { "aws": { "notify": [ @@ -209,7 +210,7 @@ async def test_notify_credential_profile(hass: HomeAssistant) -> None: ): await async_setup_component( hass, - "aws", + DOMAIN, { "aws": { "notify": [ @@ -239,7 +240,7 @@ async def test_credential_skip_validate(hass: HomeAssistant) -> None: ): await async_setup_component( hass, - "aws", + DOMAIN, { "aws": { "credentials": [ @@ -266,7 +267,7 @@ async def test_service_call_extra_data(hass: HomeAssistant) -> None: ): await async_setup_component( hass, - "aws", + DOMAIN, { "aws": { "notify": [ @@ -310,7 +311,7 @@ async def test_events_service_call(hass: HomeAssistant) -> None: ): await async_setup_component( hass, - "aws", + DOMAIN, { "aws": { "notify": [ @@ -363,7 +364,7 @@ async def test_events_service_call_10_targets(hass: HomeAssistant) -> None: ): await async_setup_component( hass, - "aws", + DOMAIN, { "aws": { "notify": [ diff --git a/tests/components/backup/test_onboarding.py b/tests/components/backup/test_onboarding.py index c70e61e89475..b3dd9a74d871 100644 --- a/tests/components/backup/test_onboarding.py +++ b/tests/components/backup/test_onboarding.py @@ -8,6 +8,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import backup, onboarding +from homeassistant.components.backup import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component @@ -56,7 +57,7 @@ async def test_onboarding_view_after_done( mock_onboarding_storage(hass_storage, {"done": [onboarding.const.STEP_USER]}) assert await async_setup_component(hass, "onboarding", {}) - assert await async_setup_component(hass, "backup", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_client() @@ -109,7 +110,7 @@ async def test_onboarding_backup_info( mock_onboarding_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) - assert await async_setup_component(hass, "backup", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_client() @@ -229,7 +230,7 @@ async def test_onboarding_backup_restore( mock_onboarding_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) - assert await async_setup_component(hass, "backup", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_client() @@ -340,7 +341,7 @@ async def test_onboarding_backup_restore_error( mock_onboarding_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) - assert await async_setup_component(hass, "backup", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_client() @@ -383,7 +384,7 @@ async def test_onboarding_backup_restore_unexpected_error( mock_onboarding_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) - assert await async_setup_component(hass, "backup", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_client() @@ -408,7 +409,7 @@ async def test_onboarding_backup_upload( mock_onboarding_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) - assert await async_setup_component(hass, "backup", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_client() diff --git a/tests/components/blebox/test_config_flow.py b/tests/components/blebox/test_config_flow.py index 56fde3720665..b44887fed3c5 100644 --- a/tests/components/blebox/test_config_flow.py +++ b/tests/components/blebox/test_config_flow.py @@ -178,7 +178,7 @@ async def test_flow_with_auth_failure(hass: HomeAssistant, product_class_mock) - async def test_async_setup(hass: HomeAssistant) -> None: """Test async_setup (for coverage).""" - assert await async_setup_component(hass, "blebox", {"host": "172.2.3.4"}) + assert await async_setup_component(hass, DOMAIN, {"host": "172.2.3.4"}) await hass.async_block_till_done() diff --git a/tests/components/blueprint/test_websocket_api.py b/tests/components/blueprint/test_websocket_api.py index 96a9323fda5c..805c50960639 100644 --- a/tests/components/blueprint/test_websocket_api.py +++ b/tests/components/blueprint/test_websocket_api.py @@ -7,6 +7,7 @@ from unittest.mock import Mock, patch import pytest import yaml +from homeassistant.components.blueprint import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.yaml import UndefinedSubstitution, parse_yaml @@ -35,7 +36,7 @@ async def setup_bp( script_config: dict[str, Any], ) -> None: """Fixture to set up the blueprint component.""" - assert await async_setup_component(hass, "blueprint", {}) + assert await async_setup_component(hass, DOMAIN, {}) # Trigger registration of automation and script blueprints await async_setup_component(hass, "automation", automation_config) diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py index 2a4a4eda4bf8..eb853737d4de 100644 --- a/tests/components/camera/conftest.py +++ b/tests/components/camera/conftest.py @@ -7,7 +7,7 @@ import pytest from webrtc_models import RTCIceCandidateInit from homeassistant.components import camera -from homeassistant.components.camera.const import StreamType +from homeassistant.components.camera.const import DOMAIN, StreamType from homeassistant.components.camera.webrtc import WebRTCAnswer, WebRTCSendMessage from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform @@ -47,7 +47,7 @@ def camera_only() -> Generator[None]: async def mock_camera_fixture(hass: HomeAssistant) -> AsyncGenerator[None]: """Initialize a demo camera platform.""" assert await async_setup_component( - hass, "camera", {camera.DOMAIN: {"platform": "demo"}} + hass, DOMAIN, {camera.DOMAIN: {"platform": "demo"}} ) await hass.async_block_till_done() @@ -260,7 +260,7 @@ async def register_test_provider( hass: HomeAssistant, ) -> AsyncGenerator[SomeTestProvider]: """Add WebRTC test provider.""" - await async_setup_component(hass, "camera", {}) + await async_setup_component(hass, DOMAIN, {}) provider = SomeTestProvider() unsub = camera.async_register_webrtc_provider(hass, provider) diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index c66d45ded215..dc873364268d 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -329,7 +329,7 @@ async def test_websocket_stream_no_source( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test camera/stream websocket command with camera with no source.""" - await async_setup_component(hass, "camera", {}) + await async_setup_component(hass, DOMAIN, {}) # Request playlist through WebSocket client = await hass_ws_client(hass) @@ -349,7 +349,7 @@ async def test_websocket_camera_stream( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_create_stream: Mock ) -> None: """Test camera/stream websocket command.""" - await async_setup_component(hass, "camera", {}) + await async_setup_component(hass, DOMAIN, {}) with patch( "homeassistant.components.demo.camera.DemoCamera.stream_source", @@ -375,7 +375,7 @@ async def test_websocket_get_prefs( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test get camera preferences websocket command.""" - await async_setup_component(hass, "camera", {}) + await async_setup_component(hass, DOMAIN, {}) # Request preferences through websocket client = await hass_ws_client(hass) @@ -563,7 +563,7 @@ async def test_no_preload_stream(hass: HomeAssistant, mock_create_stream: Mock) ) as mock_stream_source, ): mock_stream_source.return_value = io.BytesIO() - await async_setup_component(hass, "camera", {DOMAIN: {"platform": "demo"}}) + await async_setup_component(hass, DOMAIN, {DOMAIN: {"platform": "demo"}}) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() assert not mock_create_stream.endpoint_url.called @@ -583,9 +583,7 @@ async def test_preload_stream(hass: HomeAssistant, mock_create_stream: Mock) -> return_value="http://example.com", ), ): - assert await async_setup_component( - hass, "camera", {DOMAIN: {"platform": "demo"}} - ) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {"platform": "demo"}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -730,7 +728,7 @@ async def test_stream_unavailable( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_create_stream: Mock ) -> None: """Camera state.""" - await async_setup_component(hass, "camera", {}) + await async_setup_component(hass, DOMAIN, {}) with patch( "homeassistant.components.demo.camera.DemoCamera.stream_source", @@ -823,7 +821,7 @@ async def test_use_stream_for_stills( @pytest.mark.usefixtures("mock_camera") async def test_entity_picture_url_changes_on_token_update(hass: HomeAssistant) -> None: """Test the token is rotated and entity entity picture cache is cleared.""" - await async_setup_component(hass, "camera", {}) + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() camera_state = hass.states.get("camera.demo_camera") @@ -882,7 +880,7 @@ async def _test_capabilities( expected_stream_types_with_webrtc_provider: set[StreamType], ) -> None: """Test camera capabilities.""" - await async_setup_component(hass, "camera", {}) + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() async def test(expected_types: set[StreamType]) -> None: @@ -1000,7 +998,7 @@ async def test_snapshot_service_webrtc_provider( hass: HomeAssistant, ) -> None: """Test snapshot service with the webrtc provider.""" - await async_setup_component(hass, "camera", {}) + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() unsub = await _register_test_webrtc_provider(hass) camera_obj = get_camera_from_entity_id(hass, "camera.demo_camera") diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index a0f8b2c5386b..6c389c791f0d 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -8,6 +8,7 @@ import pytest from webrtc_models import RTCIceCandidate, RTCIceCandidateInit, RTCIceServer from homeassistant.components.camera import ( + DOMAIN, Camera, CameraWebRTCProvider, StreamType, @@ -105,7 +106,7 @@ async def test_ws_get_client_config( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test get WebRTC client config.""" - await async_setup_component(hass, "camera", {}) + await async_setup_component(hass, DOMAIN, {}) client = await hass_ws_client(hass) await client.send_json_auto_id( @@ -178,7 +179,7 @@ async def test_ws_get_client_config_custom_config( {"webrtc": {"ice_servers": [{"url": "stun:custom_stun_server:3478"}]}}, ) - await async_setup_component(hass, "camera", {}) + await async_setup_component(hass, DOMAIN, {}) client = await hass_ws_client(hass) await client.send_json_auto_id( @@ -199,7 +200,7 @@ async def test_ws_get_client_config_no_rtc_camera( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test get WebRTC client config.""" - await async_setup_component(hass, "camera", {}) + await async_setup_component(hass, DOMAIN, {}) client = await hass_ws_client(hass) await client.send_json_auto_id( @@ -389,7 +390,7 @@ async def test_websocket_webrtc_offer_invalid_entity( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test WebRTC with a camera entity that does not exist.""" - await async_setup_component(hass, "camera", {}) + await async_setup_component(hass, DOMAIN, {}) client = await hass_ws_client(hass) await client.send_json_auto_id( { @@ -619,7 +620,7 @@ async def test_ws_webrtc_candidate_invalid_entity( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test ws WebRTC candidate command with a camera entity that does not exist.""" - await async_setup_component(hass, "camera", {}) + await async_setup_component(hass, DOMAIN, {}) client = await hass_ws_client(hass) await client.send_json_auto_id( { diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 25902a9a831c..abd593aac848 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -2108,7 +2108,7 @@ async def test_disconnect_on_stop(hass: HomeAssistant) -> None: async def test_entry_setup_no_config(hass: HomeAssistant) -> None: """Test deprecated empty yaml config..""" - await async_setup_component(hass, "cast", {}) + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert not hass.config_entries.async_entries("cast") diff --git a/tests/components/cloud/test_binary_sensor.py b/tests/components/cloud/test_binary_sensor.py index 8a4a1a0e9aa6..b8112b346b4d 100644 --- a/tests/components/cloud/test_binary_sensor.py +++ b/tests/components/cloud/test_binary_sensor.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch from hass_nabucasa.const import DISPATCH_REMOTE_CONNECT, DISPATCH_REMOTE_DISCONNECT import pytest +from homeassistant.components.cloud import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component @@ -27,7 +28,7 @@ async def test_remote_connection_sensor( entity_id = "binary_sensor.remote_ui" cloud.remote.certificate = None - assert await async_setup_component(hass, "cloud", {"cloud": {}}) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) await hass.async_block_till_done() assert hass.states.get(entity_id) is None diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 32c9f3845a17..88d20493b570 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -167,7 +167,7 @@ async def test_handler_google_actions_disabled( mock_cloud_fixture._prefs[PREF_ENABLE_GOOGLE] = False with patch("hass_nabucasa.Cloud.initialize"): - assert await async_setup_component(hass, "cloud", {}) + assert await async_setup_component(hass, DOMAIN, {}) reqid = "5711642932632160983" data = {"requestId": reqid, "inputs": [{"intent": intent}]} @@ -189,7 +189,7 @@ async def test_handler_ice_servers( set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], ) -> None: """Test handler ICE servers.""" - assert await async_setup_component(hass, "cloud", {"cloud": {}}) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) await hass.async_block_till_done() # make sure that preferences will not be reset await cloud.client.prefs.async_set_username(cloud.username) @@ -213,7 +213,7 @@ async def test_handler_ice_servers_disabled( set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], ) -> None: """Test handler ICE servers when user has disabled it.""" - assert await async_setup_component(hass, "cloud", {"cloud": {}}) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) await hass.async_block_till_done() # make sure that preferences will not be reset await cloud.client.prefs.async_set_username(cloud.username) @@ -241,7 +241,7 @@ async def test_webhook_msg( ) -> None: """Test webhook msg.""" with patch("hass_nabucasa.Cloud.initialize"): - setup = await async_setup_component(hass, "cloud", {"cloud": {}}) + setup = await async_setup_component(hass, DOMAIN, {"cloud": {}}) assert setup cloud = hass.data[DATA_CLOUD] @@ -318,7 +318,7 @@ async def test_webhook_msg( async def test_webhook_msg_local_only(hass: HomeAssistant) -> None: """Test a cloudhook for a local_only webhook does not fire the handler.""" with patch("hass_nabucasa.Cloud.initialize"): - setup = await async_setup_component(hass, "cloud", {"cloud": {}}) + setup = await async_setup_component(hass, DOMAIN, {"cloud": {}}) assert setup cloud = hass.data[DATA_CLOUD] @@ -453,7 +453,7 @@ async def test_login_recovers_bad_internet( async def test_system_msg(hass: HomeAssistant) -> None: """Test system msg.""" with patch("hass_nabucasa.Cloud.initialize"): - setup = await async_setup_component(hass, "cloud", {"cloud": {}}) + setup = await async_setup_component(hass, DOMAIN, {"cloud": {}}) assert setup cloud = hass.data[DATA_CLOUD] @@ -476,7 +476,7 @@ async def test_cloud_connection_info(hass: HomeAssistant) -> None: patch("uuid.UUID.hex", new_callable=PropertyMock) as hexmock, ): hexmock.return_value = "12345678901234567890" - setup = await async_setup_component(hass, "cloud", {"cloud": {}}) + setup = await async_setup_component(hass, DOMAIN, {"cloud": {}}) assert setup cloud = hass.data[DATA_CLOUD] @@ -598,7 +598,7 @@ async def test_logged_out( ) -> None: """Test cleanup when logged out from the cloud.""" - assert await async_setup_component(hass, "cloud", {"cloud": {}}) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) await hass.async_block_till_done() await cloud.login("test-user", "test-pass") diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index a341da6fad43..8bd769c8c4d4 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -35,7 +35,7 @@ async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None: with patch("hass_nabucasa.Cloud.initialize"): result = await async_setup_component( hass, - "cloud", + DOMAIN, { "http": {}, "cloud": { @@ -138,7 +138,7 @@ async def test_setup_existing_cloud_user( with patch("hass_nabucasa.Cloud.initialize"): result = await async_setup_component( hass, - "cloud", + DOMAIN, { "http": {}, "cloud": { @@ -248,7 +248,7 @@ async def test_async_get_or_create_cloudhook( set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], ) -> None: """Test async_get_or_create_cloudhook.""" - assert await async_setup_component(hass, "cloud", {"cloud": {}}) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) await hass.async_block_till_done() await cloud.login("test-user", "test-pass") @@ -318,7 +318,7 @@ async def test_async_listen_cloudhook_change( set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], ) -> None: """Test async_listen_cloudhook_change.""" - assert await async_setup_component(hass, "cloud", {"cloud": {}}) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) await hass.async_block_till_done() await cloud.login("test-user", "test-pass") @@ -430,7 +430,7 @@ async def test_async_listen_cloudhook_change_cloud_setup_later( assert len(changes) == 0 # Now set up cloud - assert await async_setup_component(hass, "cloud", {"cloud": {}}) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) await hass.async_block_till_done() await cloud.login("test-user", "test-pass") diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index f05ee3b1fd23..c66d7d8b38dc 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -8,7 +8,7 @@ from unittest.mock import patch import pytest from homeassistant.components import config -from homeassistant.components.config import automation +from homeassistant.components.config import DOMAIN, automation from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -38,7 +38,7 @@ async def test_get_automation_config( ) -> None: """Test getting automation config.""" with patch.object(config, "SECTIONS", [automation]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) client = await hass_client() @@ -61,7 +61,7 @@ async def test_update_automation_config( ) -> None: """Test updating automation config.""" with patch.object(config, "SECTIONS", [automation]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) assert sorted(hass.states.async_entity_ids("automation")) == [] @@ -153,7 +153,7 @@ async def test_update_automation_config_with_error( ) -> None: """Test updating automation config with errors.""" with patch.object(config, "SECTIONS", [automation]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) assert sorted(hass.states.async_entity_ids("automation")) == [] @@ -206,7 +206,7 @@ async def test_update_automation_config_with_blueprint_substitution_error( ) -> None: """Test updating automation config with errors.""" with patch.object(config, "SECTIONS", [automation]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) assert sorted(hass.states.async_entity_ids("automation")) == [] @@ -242,7 +242,7 @@ async def test_update_remove_key_automation_config( ) -> None: """Test updating automation config while removing a key.""" with patch.object(config, "SECTIONS", [automation]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) assert sorted(hass.states.async_entity_ids("automation")) == [] @@ -284,7 +284,7 @@ async def test_bad_formatted_automations( ) -> None: """Test that we handle automations without ID.""" with patch.object(config, "SECTIONS", [automation]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) assert sorted(hass.states.async_entity_ids("automation")) == [] @@ -353,7 +353,7 @@ async def test_delete_automation( assert len(entity_registry.entities) == 2 with patch.object(config, "SECTIONS", [automation]): - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert sorted(hass.states.async_entity_ids("automation")) == [ "automation.automation_0", @@ -391,7 +391,7 @@ async def test_api_calls_require_admin( ) -> None: """Test cloud APIs endpoints do not work as a normal user.""" with patch.object(config, "SECTIONS", [automation]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) hass_config_store["automations.yaml"] = [{"id": "sun"}, {"id": "moon"}] diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index c7e9118e6524..3077c55f0609 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -12,7 +12,7 @@ from pytest_unordered import unordered import voluptuous as vol from homeassistant import config_entries as core_ce, data_entry_flow, loader -from homeassistant.components.config import config_entries +from homeassistant.components.config import DOMAIN, config_entries from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback @@ -735,7 +735,7 @@ async def test_get_progress_index( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test querying for the flows that are in progress.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) mock_platform(hass, "test.config_flow", None) ws_client = await hass_ws_client(hass) @@ -806,7 +806,7 @@ async def test_get_progress_index_unauth( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_admin_user: MockUser ) -> None: """Test we can't get flows that are in progress.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) hass_admin_user.groups = [] ws_client = await hass_ws_client(hass) @@ -890,7 +890,7 @@ async def test_get_progress_subscribe( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test querying for the flows that are in progress.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) mock_platform(hass, "test.config_flow", None) ws_client = await hass_ws_client(hass) @@ -1003,7 +1003,7 @@ async def test_get_progress_subscribe( async def test_get_progress_subscribe_create_entry(hass: HomeAssistant) -> None: """Test flows creating entry immediately don't trigger subscription notification.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) mock_platform(hass, "test.config_flow", None) mock_integration( @@ -1035,7 +1035,7 @@ async def test_get_progress_subscribe_in_progress( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test querying for the flows that are in progress.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) mock_platform(hass, "test.config_flow", None) ws_client = await hass_ws_client(hass) @@ -1157,7 +1157,7 @@ async def test_get_progress_subscribe_in_progress_bad_flow( caplog: pytest.LogCaptureFixture, ) -> None: """Test querying for the flows that are in progress.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) mock_platform(hass, "test.config_flow", None) mock_platform(hass, "test2.config_flow", None) ws_client = await hass_ws_client(hass) @@ -1283,7 +1283,7 @@ async def test_get_progress_subscribe_unauth( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_admin_user: MockUser ) -> None: """Test we can't subscribe to flows.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) hass_admin_user.groups = [] ws_client = await hass_ws_client(hass) @@ -2023,7 +2023,7 @@ async def test_get_single( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test that we can get a config entry.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) ws_client = await hass_ws_client(hass) entry = MockConfigEntry(domain="test", state=core_ce.ConfigEntryState.LOADED) @@ -2082,7 +2082,7 @@ async def test_update_prefrences( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test that we can update system options.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) ws_client = await hass_ws_client(hass) entry = MockConfigEntry(domain="test", state=core_ce.ConfigEntryState.LOADED) @@ -2134,7 +2134,7 @@ async def test_update_entry( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test that we can update entry.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) ws_client = await hass_ws_client(hass) entry = MockConfigEntry(domain="demo", title="Initial Title") @@ -2159,7 +2159,7 @@ async def test_update_entry_nonexisting( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test that we can update entry.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) ws_client = await hass_ws_client(hass) await ws_client.send_json( @@ -2180,7 +2180,7 @@ async def test_disable_entry( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test that we can disable entry.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) ws_client = await hass_ws_client(hass) entry = MockConfigEntry(domain="test", state=core_ce.ConfigEntryState.LOADED) @@ -2241,7 +2241,7 @@ async def test_disable_entry_nonexisting( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test that we can disable entry.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) ws_client = await hass_ws_client(hass) await ws_client.send_json( @@ -2281,7 +2281,7 @@ async def test_ignore_flow( entry_discovery_keys: dict[str, tuple[DiscoveryKey, ...]], ) -> None: """Test we can ignore a flow.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) mock_integration( hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) ) @@ -2331,7 +2331,7 @@ async def test_ignore_flow_nonexisting( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test we can ignore a flow.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) ws_client = await hass_ws_client(hass) await ws_client.send_json( @@ -2353,7 +2353,7 @@ async def test_get_matching_entries_ws( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test get entries with the websocket api.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) mock_integration(hass, MockModule("comp1")) mock_integration( hass, MockModule("comp2", partial_manifest={"integration_type": "helper"}) @@ -2806,7 +2806,7 @@ async def test_subscribe_entries_ws( freezer: FrozenDateTimeFactory, ) -> None: """Test subscribe entries with the websocket api.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) mock_integration(hass, MockModule("comp1")) mock_integration( hass, MockModule("comp2", partial_manifest={"integration_type": "helper"}) @@ -3025,7 +3025,7 @@ async def test_subscribe_entries_ws_filtered( ) -> None: """Test subscribe entries with the websocket api with a type filter.""" created = utcnow().timestamp() - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) mock_integration(hass, MockModule("comp1")) mock_integration( hass, MockModule("comp2", partial_manifest={"integration_type": "helper"}) @@ -3453,7 +3453,7 @@ async def test_list_subentries( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test that we can list subentries.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) ws_client = await hass_ws_client(hass) entry = MockConfigEntry( @@ -3512,7 +3512,7 @@ async def test_update_subentry( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test that we can update a subentry.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) ws_client = await hass_ws_client(hass) entry = MockConfigEntry( @@ -3588,7 +3588,7 @@ async def test_delete_subentry( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test that we can delete a subentry.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) ws_client = await hass_ws_client(hass) entry = MockConfigEntry( diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py index f48faf72e97f..12dec880c4f5 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -6,7 +6,7 @@ from unittest.mock import Mock, patch import pytest from homeassistant.components import config -from homeassistant.components.config import core +from homeassistant.components.config import DOMAIN, core from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -27,7 +27,7 @@ async def client( ) -> MockHAClientWebSocket: """Fixture that can interact with the config manager API.""" with patch.object(config, "SECTIONS", [core]): - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) return await hass_ws_client(hass) @@ -36,7 +36,7 @@ async def test_validate_config_ok( ) -> None: """Test checking config.""" with patch.object(config, "SECTIONS", [core]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) client = await hass_client() @@ -99,7 +99,7 @@ async def test_validate_config_requires_admin( ) -> None: """Test checking configuration does not work as a normal user.""" with patch.object(config, "SECTIONS", [core]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) client = await hass_client(hass_read_only_access_token) resp = await client.post("/api/config/core/check_config") @@ -195,7 +195,7 @@ async def test_websocket_core_update_not_admin( """Test core config fails for non admin.""" hass_admin_user.groups = [] with patch.object(config, "SECTIONS", [core]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) client = await hass_ws_client(hass) await client.send_json({"id": 6, "type": "config/core/update", "latitude": 23}) diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 2d11952bc065..4c0f5f18e3bc 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -6,7 +6,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from pytest_unordered import unordered -from homeassistant.components.config import device_registry +from homeassistant.components.config import DOMAIN, device_registry from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -268,7 +268,7 @@ async def test_remove_config_entry_from_device( device_registry: dr.DeviceRegistry, ) -> None: """Test removing config entry from device.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) ws_client = await hass_ws_client(hass) can_remove = False @@ -354,7 +354,7 @@ async def test_remove_config_entry_from_device_fails( device_registry: dr.DeviceRegistry, ) -> None: """Test removing config entry from device failing cases.""" - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) ws_client = await hass_ws_client(hass) async def async_remove_config_entry_device( @@ -474,7 +474,7 @@ async def test_remove_config_entry_from_device_if_integration_remove( Should not error when the integration removes the entry. """ - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) ws_client = await hass_ws_client(hass) can_remove = False diff --git a/tests/components/config/test_init.py b/tests/components/config/test_init.py index 135cea28eff0..c01b95621da8 100644 --- a/tests/components/config/test_init.py +++ b/tests/components/config/test_init.py @@ -1,10 +1,11 @@ """Test config init.""" +from homeassistant.components.config import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component async def test_config_setup(hass: HomeAssistant) -> None: """Test it sets up hassbian.""" - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) assert "config" in hass.config.components diff --git a/tests/components/config/test_scene.py b/tests/components/config/test_scene.py index c4c207f33f9f..8470afc38915 100644 --- a/tests/components/config/test_scene.py +++ b/tests/components/config/test_scene.py @@ -8,7 +8,7 @@ from unittest.mock import ANY, patch import pytest from homeassistant.components import config -from homeassistant.components.config import scene +from homeassistant.components.config import DOMAIN, scene from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -32,7 +32,7 @@ async def test_create_scene( ) -> None: """Test creating a scene.""" with patch.object(config, "SECTIONS", [scene]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) assert sorted(hass.states.async_entity_ids("scene")) == [] @@ -79,7 +79,7 @@ async def test_update_scene( ) -> None: """Test updating a scene.""" with patch.object(config, "SECTIONS", [scene]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) assert sorted(hass.states.async_entity_ids("scene")) == [] @@ -127,7 +127,7 @@ async def test_bad_formatted_scene( ) -> None: """Test that we handle scene without ID.""" with patch.object(config, "SECTIONS", [scene]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) assert sorted(hass.states.async_entity_ids("scene")) == [] @@ -197,7 +197,7 @@ async def test_delete_scene( assert len(entity_registry.entities) == 2 with patch.object(config, "SECTIONS", [scene]): - assert await async_setup_component(hass, "config", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert sorted(hass.states.async_entity_ids("scene")) == [ "scene.light_off", @@ -237,7 +237,7 @@ async def test_api_calls_require_admin( ) -> None: """Test scene APIs endpoints do not work as a normal user.""" with patch.object(config, "SECTIONS", [scene]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) hass_config_store["scenes.yaml"] = [ { diff --git a/tests/components/config/test_script.py b/tests/components/config/test_script.py index c5e4585af894..39244051d897 100644 --- a/tests/components/config/test_script.py +++ b/tests/components/config/test_script.py @@ -8,7 +8,7 @@ from unittest.mock import patch import pytest from homeassistant.components import config -from homeassistant.components.config import script +from homeassistant.components.config import DOMAIN, script from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -32,7 +32,7 @@ async def test_get_script_config( ) -> None: """Test getting script config.""" with patch.object(config, "SECTIONS", [script]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) client = await hass_client() @@ -57,7 +57,7 @@ async def test_update_script_config( ) -> None: """Test updating script config.""" with patch.object(config, "SECTIONS", [script]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) assert sorted(hass.states.async_entity_ids("script")) == [] @@ -95,7 +95,7 @@ async def test_invalid_object_id( ) -> None: """Test creating a script with an invalid object_id.""" with patch.object(config, "SECTIONS", [script]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) assert sorted(hass.states.async_entity_ids("script")) == [] @@ -160,7 +160,7 @@ async def test_update_script_config_with_error( ) -> None: """Test updating script config with errors.""" with patch.object(config, "SECTIONS", [script]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) assert sorted(hass.states.async_entity_ids("script")) == [] @@ -210,7 +210,7 @@ async def test_update_script_config_with_blueprint_substitution_error( ) -> None: """Test updating script config with errors.""" with patch.object(config, "SECTIONS", [script]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) assert sorted(hass.states.async_entity_ids("script")) == [] @@ -245,7 +245,7 @@ async def test_update_remove_key_script_config( ) -> None: """Test updating script config while removing a key.""" with patch.object(config, "SECTIONS", [script]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) assert sorted(hass.states.async_entity_ids("script")) == [] @@ -292,7 +292,7 @@ async def test_delete_script( ) -> None: """Test deleting a script.""" with patch.object(config, "SECTIONS", [script]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) assert sorted(hass.states.async_entity_ids("script")) == [ "script.one", @@ -331,7 +331,7 @@ async def test_api_calls_require_admin( ) -> None: """Test script APIs endpoints do not work as a normal user.""" with patch.object(config, "SECTIONS", [script]): - await async_setup_component(hass, "config", {}) + await async_setup_component(hass, DOMAIN, {}) hass_config_store["scripts.yaml"] = { "moon": {"alias": "Moon"}, diff --git a/tests/components/conversation/conftest.py b/tests/components/conversation/conftest.py index f7d674769eb1..e847888cfd1c 100644 --- a/tests/components/conversation/conftest.py +++ b/tests/components/conversation/conftest.py @@ -6,7 +6,7 @@ from unittest.mock import Mock, patch import pytest from homeassistant.components import conversation -from homeassistant.components.conversation import async_get_agent, default_agent +from homeassistant.components.conversation import DOMAIN, async_get_agent, default_agent from homeassistant.components.shopping_list import intent as sl_intent from homeassistant.const import MATCH_ALL from homeassistant.core import Context, HomeAssistant @@ -75,7 +75,7 @@ async def sl_setup(hass: HomeAssistant): async def init_components(hass: HomeAssistant): """Initialize relevant components with empty configs.""" assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "conversation", {conversation.DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {conversation.DOMAIN: {}}) # Disable fuzzy matching by default for tests agent = async_get_agent(hass) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 30c3ae395ff3..4226c42e9595 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -13,6 +13,7 @@ import yaml from homeassistant.components import conversation, cover, media_player, weather from homeassistant.components.conversation import ( + DOMAIN, async_get_agent, default_agent, get_agent_manager, @@ -87,7 +88,7 @@ class OrderBeerIntentHandler(intent.IntentHandler): async def init_components(hass: HomeAssistant) -> None: """Initialize relevant components with empty configs.""" assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "conversation", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert await async_setup_component(hass, "intent", {}) @@ -266,7 +267,7 @@ async def test_expose_flag_automatically_set( assert async_get_assistant_settings(hass, conversation.DOMAIN) == {} - assert await async_setup_component(hass, "conversation", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() with patch("homeassistant.components.http.start_http_server_and_save_config"): await hass.async_start() @@ -2486,7 +2487,7 @@ async def test_custom_sentences_config( assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component( hass, - "conversation", + DOMAIN, {"conversation": {"intents": {"StealthMode": ["engage stealth mode"]}}}, ) assert await async_setup_component(hass, "intent", {}) @@ -2723,7 +2724,7 @@ async def test_custom_sentences_priority( custom_sentences_file.seek(0) assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "conversation", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert await async_setup_component(hass, "light", {}) assert await async_setup_component(hass, "intent", {}) assert await async_setup_component( @@ -2768,7 +2769,7 @@ async def test_config_sentences_priority( assert await async_setup_component(hass, "intent", {}) assert await async_setup_component( hass, - "conversation", + DOMAIN, { "conversation": { "intents": { diff --git a/tests/components/conversation/test_default_agent_intents.py b/tests/components/conversation/test_default_agent_intents.py index a84f491b4345..b2310ea562b6 100644 --- a/tests/components/conversation/test_default_agent_intents.py +++ b/tests/components/conversation/test_default_agent_intents.py @@ -14,6 +14,7 @@ from homeassistant.components import ( vacuum, valve, ) +from homeassistant.components.conversation import DOMAIN from homeassistant.components.cover import intent as cover_intent from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.media_player import ( @@ -65,7 +66,7 @@ class MockTodoListEntity(todo.TodoListEntity): async def init_components(hass: HomeAssistant): """Initialize relevant components with empty configs.""" assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "conversation", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert await async_setup_component(hass, "intent", {}) diff --git a/tests/components/conversation/test_entity.py b/tests/components/conversation/test_entity.py index f03b24818bff..4bc8464d6d7c 100644 --- a/tests/components/conversation/test_entity.py +++ b/tests/components/conversation/test_entity.py @@ -3,6 +3,7 @@ from unittest.mock import patch from homeassistant.components import conversation +from homeassistant.components.conversation import DOMAIN from homeassistant.core import Context, HomeAssistant, State from homeassistant.helpers import intent from homeassistant.setup import async_setup_component @@ -18,7 +19,7 @@ async def test_state_set_and_restore(hass: HomeAssistant) -> None: mock_restore_cache(hass, (State(entity_id, timestamp),)) await async_setup_component(hass, "homeassistant", {}) - await async_setup_component(hass, "conversation", {}) + await async_setup_component(hass, DOMAIN, {}) state = hass.states.get(entity_id) assert state diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 2999f12a786c..0b38ee3cf270 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.components import conversation from homeassistant.components.conversation import ( + DOMAIN, ConversationInput, async_get_agent, async_get_chat_log, @@ -269,7 +270,7 @@ async def test_async_handle_sentence_triggers( ) -> None: """Test handling sentence triggers with async_handle_sentence_triggers.""" assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "conversation", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert await async_setup_component( hass, @@ -311,7 +312,7 @@ async def test_async_handle_sentence_triggers( async def test_async_handle_intents(hass: HomeAssistant) -> None: """Test handling registered intents with async_handle_intents.""" assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "conversation", {}) + assert await async_setup_component(hass, DOMAIN, {}) # Reuse custom sentences in test config to trigger default agent. class OrderBeerIntentHandler(intent.IntentHandler): diff --git a/tests/components/conversation/test_trace.py b/tests/components/conversation/test_trace.py index a975c9b7983f..46a280175e42 100644 --- a/tests/components/conversation/test_trace.py +++ b/tests/components/conversation/test_trace.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest from homeassistant.components import conversation -from homeassistant.components.conversation import trace +from homeassistant.components.conversation import DOMAIN, trace from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component @@ -15,7 +15,7 @@ from homeassistant.setup import async_setup_component async def init_components(hass: HomeAssistant): """Initialize relevant components with empty configs.""" assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "conversation", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert await async_setup_component(hass, "intent", {}) diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 745b7ebbe13f..d54da57b38af 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant import loader from homeassistant.components import automation, device_automation from homeassistant.components.device_automation import ( + DOMAIN, InvalidDeviceAutomationConfig, toggle_entity, ) @@ -107,7 +108,7 @@ async def test_websocket_get_actions( fake_integration, ) -> None: """Test we get the expected actions through websocket.""" - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( @@ -162,7 +163,7 @@ async def test_websocket_get_conditions( fake_integration, ) -> None: """Test we get the expected conditions through websocket.""" - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( @@ -216,7 +217,7 @@ async def test_websocket_get_triggers( fake_integration, ) -> None: """Test we get the expected triggers through websocket.""" - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( @@ -278,7 +279,7 @@ async def test_websocket_get_action_capabilities( fake_integration, ) -> None: """Test we get the expected action capabilities through websocket.""" - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( @@ -347,7 +348,7 @@ async def test_websocket_get_action_capabilities_unknown_domain( entity_registry: er.EntityRegistry, ) -> None: """Test we get no action capabilities for a non existing domain.""" - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) expected_capabilities = {} client = await hass_ws_client(hass) @@ -378,7 +379,7 @@ async def test_websocket_get_action_capabilities_no_capabilities( The tests tests a domain which has a device action platform, but no async_get_action_capabilities. """ - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) expected_capabilities = {} client = await hass_ws_client(hass) @@ -405,7 +406,7 @@ async def test_websocket_get_action_capabilities_bad_action( fake_integration, ) -> None: """Test we get no action capabilities when there is an error.""" - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) expected_capabilities = {} module_cache = hass.data[loader.DATA_COMPONENTS] @@ -439,7 +440,7 @@ async def test_websocket_get_condition_capabilities( fake_integration, ) -> None: """Test we get the expected condition capabilities through websocket.""" - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( @@ -511,7 +512,7 @@ async def test_websocket_get_condition_capabilities_unknown_domain( entity_registry: er.EntityRegistry, ) -> None: """Test we get no condition capabilities for a non existing domain.""" - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) expected_capabilities = {} client = await hass_ws_client(hass) @@ -542,7 +543,7 @@ async def test_websocket_get_condition_capabilities_no_capabilities( The tests tests a domain which has a device condition platform, but no async_get_condition_capabilities. """ - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) expected_capabilities = {} client = await hass_ws_client(hass) @@ -573,7 +574,7 @@ async def test_websocket_get_condition_capabilities_bad_condition( fake_integration, ) -> None: """Test we get no condition capabilities when there is an error.""" - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) expected_capabilities = {} module_cache = hass.data[loader.DATA_COMPONENTS] @@ -609,7 +610,7 @@ async def test_async_get_device_automations_single_device_trigger( entity_registry: er.EntityRegistry, ) -> None: """Test we get can fetch the triggers for a device id.""" - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( @@ -632,7 +633,7 @@ async def test_async_get_device_automations_all_devices_trigger( entity_registry: er.EntityRegistry, ) -> None: """Test we get can fetch all the triggers when no device id is passed.""" - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( @@ -655,7 +656,7 @@ async def test_async_get_device_automations_all_devices_condition( entity_registry: er.EntityRegistry, ) -> None: """Test we get can fetch all the conditions when no device id is passed.""" - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( @@ -678,7 +679,7 @@ async def test_async_get_device_automations_all_devices_action( entity_registry: er.EntityRegistry, ) -> None: """Test we get can fetch all the actions when no device id is passed.""" - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( @@ -702,7 +703,7 @@ async def test_async_get_device_automations_all_devices_action_exception_throw( caplog: pytest.LogCaptureFixture, ) -> None: """Test we can fetch all actions with no device id and handle exceptions.""" - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( @@ -737,7 +738,7 @@ async def test_websocket_get_trigger_capabilities( trigger_key: str, ) -> None: """Test we get the expected trigger capabilities through websocket.""" - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( @@ -810,7 +811,7 @@ async def test_websocket_get_trigger_capabilities_unknown_domain( entity_registry: er.EntityRegistry, ) -> None: """Test we get no trigger capabilities for a non existing domain.""" - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) expected_capabilities = {} client = await hass_ws_client(hass) @@ -841,7 +842,7 @@ async def test_websocket_get_trigger_capabilities_no_capabilities( The tests tests a domain which has a device trigger platform, but no async_get_trigger_capabilities. """ - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) expected_capabilities = {} client = await hass_ws_client(hass) @@ -872,7 +873,7 @@ async def test_websocket_get_trigger_capabilities_bad_trigger( fake_integration, ) -> None: """Test we get no trigger capabilities when there is an error.""" - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) expected_capabilities = {} module_cache = hass.data[loader.DATA_COMPONENTS] @@ -1597,7 +1598,7 @@ async def test_websocket_device_not_found( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test calling command with unknown device.""" - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) client = await hass_ws_client(hass) await client.send_json( {"id": 1, "type": "device_automation/action/list", "device_id": "non-existing"} @@ -1733,7 +1734,7 @@ async def test_async_get_device_automations_platform_reraises_exceptions( hass: HomeAssistant, exc: Exception ) -> None: """Test InvalidDeviceAutomationConfig is raised when get_integration fails.""" - await async_setup_component(hass, "device_automation", {}) + await async_setup_component(hass, DOMAIN, {}) with ( patch( "homeassistant.components.device_automation.async_get_integration_with_requirements", diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 482f1bb9e60e..1bb1417d4b85 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -9,6 +9,7 @@ import pytest from homeassistant.components import device_tracker, zone from homeassistant.components.device_tracker import ( + DOMAIN, SourceType, TrackerEntity, TrackingType, @@ -795,7 +796,7 @@ async def test_modern_platform_setup(hass: HomeAssistant) -> None: ) await async_setup_component(hass, "homeassistant", {}) - await async_setup_component(hass, "device_tracker", {}) + await async_setup_component(hass, DOMAIN, {}) await async_setup_component(hass, test_domain, {}) await hass.async_block_till_done() diff --git a/tests/components/eafm/test_sensor.py b/tests/components/eafm/test_sensor.py index 5dbcbf906655..0348b6902316 100644 --- a/tests/components/eafm/test_sensor.py +++ b/tests/components/eafm/test_sensor.py @@ -42,7 +42,7 @@ async def async_setup_test_fixture( ) entry.add_to_hass(hass) - assert await async_setup_component(hass, "eafm", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert entry.state is ConfigEntryState.LOADED await hass.async_block_till_done() diff --git a/tests/components/emoncms_history/test_init.py b/tests/components/emoncms_history/test_init.py index c62252750b54..e9c03610e732 100644 --- a/tests/components/emoncms_history/test_init.py +++ b/tests/components/emoncms_history/test_init.py @@ -8,6 +8,7 @@ import aiohttp from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.emoncms_history import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_URL, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -27,14 +28,14 @@ async def test_setup_valid_config(hass: HomeAssistant) -> None: hass.states.async_set("sensor.temp", "23.4", {"unit_of_measurement": "°C"}) await hass.async_block_till_done() - assert await async_setup_component(hass, "emoncms_history", config) + assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() async def test_setup_missing_config(hass: HomeAssistant) -> None: """Test setting up the emoncms_history component with missing configuration.""" config = {"emoncms_history": {"api_key": "dummy"}} - success = await async_setup_component(hass, "emoncms_history", config) + success = await async_setup_component(hass, DOMAIN, config) assert not success @@ -66,7 +67,7 @@ async def test_emoncms_send_data( } } - assert await async_setup_component(hass, "emoncms_history", config) + assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() for state in None, "", STATE_UNAVAILABLE, STATE_UNKNOWN: diff --git a/tests/components/emulated_hue/test_init.py b/tests/components/emulated_hue/test_init.py index 6bc99db6e604..75dd6fcb80f4 100644 --- a/tests/components/emulated_hue/test_init.py +++ b/tests/components/emulated_hue/test_init.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, Mock, patch from aiohttp import web +from homeassistant.components.emulated_hue import DOMAIN from homeassistant.components.emulated_hue.config import ( DATA_KEY, DATA_VERSION, @@ -147,7 +148,7 @@ async def test_setup_works(hass: HomeAssistant) -> None: mock_create_upnp_datagram_endpoint.return_value = AsyncMock( spec=UPNPResponderProtocol ) - assert await async_setup_component(hass, "emulated_hue", {}) + assert await async_setup_component(hass, DOMAIN, {}) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() diff --git a/tests/components/energy/conftest.py b/tests/components/energy/conftest.py index dae67af413c8..615fb788f39a 100644 --- a/tests/components/energy/conftest.py +++ b/tests/components/energy/conftest.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest -from homeassistant.components.energy import async_get_manager +from homeassistant.components.energy import DOMAIN, async_get_manager from homeassistant.components.energy.data import EnergyManager from homeassistant.components.recorder import Recorder from homeassistant.core import HomeAssistant @@ -50,7 +50,7 @@ async def mock_energy_manager( recorder_mock: Recorder, hass: HomeAssistant ) -> EnergyManager: """Set up energy.""" - assert await async_setup_component(hass, "energy", {"energy": {}}) + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) manager = await async_get_manager(hass) manager.data = manager.default_preferences() return manager diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index cdde511c2e56..6ce13d86fc2a 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -8,7 +8,7 @@ from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.energy import async_get_manager, data +from homeassistant.components.energy import DOMAIN, async_get_manager, data from homeassistant.components.energy.sensor import ( EnergyCostSensor, EnergyPowerSensor, @@ -55,7 +55,7 @@ async def setup_integration( """Set up the integration.""" async def setup_integration(hass: HomeAssistant) -> None: - assert await async_setup_component(hass, "energy", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() return setup_integration @@ -1394,7 +1394,7 @@ async def test_power_sensor_manager_creation( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test SensorManager creates power sensors correctly.""" - assert await async_setup_component(hass, "energy", {"energy": {}}) + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) manager = await async_get_manager(hass) manager.data = manager.default_preferences() @@ -1434,7 +1434,7 @@ async def test_power_sensor_inverted_propagates_unit( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test inverted power sensor copies unit from the source state.""" - assert await async_setup_component(hass, "energy", {"energy": {}}) + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) manager = await async_get_manager(hass) manager.data = manager.default_preferences() @@ -1486,7 +1486,7 @@ async def test_power_sensor_inverted_source_without_unit( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test inverted sensor reports no unit when source has none.""" - assert await async_setup_component(hass, "energy", {"energy": {}}) + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) manager = await async_get_manager(hass) manager.data = manager.default_preferences() @@ -1519,7 +1519,7 @@ async def test_power_sensor_manager_cleanup( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test SensorManager removes power sensors when config changes.""" - assert await async_setup_component(hass, "energy", {"energy": {}}) + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) manager = await async_get_manager(hass) manager.data = manager.default_preferences() @@ -1574,7 +1574,7 @@ async def test_power_sensor_grid_combined( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test power sensor for grid with combined config.""" - assert await async_setup_component(hass, "energy", {"energy": {}}) + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) manager = await async_get_manager(hass) manager.data = manager.default_preferences() @@ -1625,7 +1625,7 @@ async def test_power_sensor_device_assignment( device_registry: dr.DeviceRegistry, ) -> None: """Test power sensor is assigned to same device as source sensor.""" - assert await async_setup_component(hass, "energy", {"energy": {}}) + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) manager = await async_get_manager(hass) manager.data = manager.default_preferences() @@ -1687,7 +1687,7 @@ async def test_power_sensor_device_assignment_combined_second_sensor( device_registry: dr.DeviceRegistry, ) -> None: """Test power sensor checks second sensor if first has no device.""" - assert await async_setup_component(hass, "energy", {"energy": {}}) + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) manager = await async_get_manager(hass) manager.data = manager.default_preferences() @@ -1762,7 +1762,7 @@ async def test_power_sensor_inverted_availability( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test inverted power sensor availability follows source sensor.""" - assert await async_setup_component(hass, "energy", {"energy": {}}) + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) manager = await async_get_manager(hass) manager.data = manager.default_preferences() @@ -1815,7 +1815,7 @@ async def test_power_sensor_combined_availability( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test combined power sensor availability requires both sources available.""" - assert await async_setup_component(hass, "energy", {"energy": {}}) + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) manager = await async_get_manager(hass) manager.data = manager.default_preferences() @@ -1887,7 +1887,7 @@ async def test_power_sensor_battery_combined( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test power sensor for battery with combined config.""" - assert await async_setup_component(hass, "energy", {"energy": {}}) + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) manager = await async_get_manager(hass) manager.data = manager.default_preferences() @@ -1947,7 +1947,7 @@ async def test_power_sensor_combined_unit_conversion( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test power sensor combined mode with different units.""" - assert await async_setup_component(hass, "energy", {"energy": {}}) + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) manager = await async_get_manager(hass) manager.data = manager.default_preferences() @@ -1996,7 +1996,7 @@ async def test_power_sensor_inverted_negative_values( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test inverted power sensor with negative source values.""" - assert await async_setup_component(hass, "energy", {"energy": {}}) + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) manager = await async_get_manager(hass) manager.data = manager.default_preferences() @@ -2074,7 +2074,7 @@ async def test_energy_data_removal( }, ) - assert await async_setup_component(hass, "energy", {"energy": {}}) + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) await hass.async_block_till_done() # Verify cost sensor was created @@ -2331,7 +2331,7 @@ async def test_power_sensor_inverted_invalid_value( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test inverted power sensor with invalid source value.""" - assert await async_setup_component(hass, "energy", {"energy": {}}) + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) manager = await async_get_manager(hass) manager.data = manager.default_preferences() @@ -2375,7 +2375,7 @@ async def test_power_sensor_combined_invalid_value( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test combined power sensor with invalid source value.""" - assert await async_setup_component(hass, "energy", {"energy": {}}) + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) manager = await async_get_manager(hass) manager.data = manager.default_preferences() @@ -2447,7 +2447,7 @@ async def test_power_sensor_naming_fallback( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test power sensor naming when source not in registry.""" - assert await async_setup_component(hass, "energy", {"energy": {}}) + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) manager = await async_get_manager(hass) manager.data = manager.default_preferences() @@ -2485,7 +2485,7 @@ async def test_power_sensor_no_device_assignment( entity_registry: er.EntityRegistry, ) -> None: """Test power sensor when source sensors have no device.""" - assert await async_setup_component(hass, "energy", {"energy": {}}) + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) manager = await async_get_manager(hass) manager.data = manager.default_preferences() @@ -2528,7 +2528,7 @@ async def test_power_sensor_keeps_existing_on_update( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test that existing power sensor is kept when config doesn't change.""" - assert await async_setup_component(hass, "energy", {"energy": {}}) + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) manager = await async_get_manager(hass) manager.data = manager.default_preferences() @@ -2638,7 +2638,7 @@ async def test_power_sensor_naming_with_registry_name( entity_registry: er.EntityRegistry, ) -> None: """Test power sensor naming uses registry name when available.""" - assert await async_setup_component(hass, "energy", {"energy": {}}) + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) manager = await async_get_manager(hass) manager.data = manager.default_preferences() diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index 9c0334595de1..14512d56d22e 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock import pytest -from homeassistant.components.energy import data, is_configured +from homeassistant.components.energy import DOMAIN, data, is_configured from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.models import StatisticMeanType from homeassistant.components.recorder.statistics import async_add_external_statistics @@ -24,7 +24,7 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) async def setup_integration(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Set up the integration.""" - assert await async_setup_component(hass, "energy", {}) + assert await async_setup_component(hass, DOMAIN, {}) @pytest.fixture diff --git a/tests/components/esphome/test_ffmpeg_proxy.py b/tests/components/esphome/test_ffmpeg_proxy.py index 35a4b636c9d5..8aab77575580 100644 --- a/tests/components/esphome/test_ffmpeg_proxy.py +++ b/tests/components/esphome/test_ffmpeg_proxy.py @@ -16,6 +16,7 @@ import mutagen import pytest from homeassistant.components import esphome +from homeassistant.components.esphome import DOMAIN from homeassistant.components.esphome.ffmpeg_proxy import ( _MAX_STDERR_LINES, async_create_proxy_url, @@ -53,7 +54,7 @@ def _write_silence(filename: str, length: int) -> None: async def test_async_create_proxy_url(hass: HomeAssistant) -> None: """Test that async_create_proxy_url returns the correct format.""" - assert await async_setup_component(hass, "esphome", {}) + assert await async_setup_component(hass, DOMAIN, {}) device_id = "test-device" convert_id = "test-id" diff --git a/tests/components/evil_genius_labs/conftest.py b/tests/components/evil_genius_labs/conftest.py index 0ce805dad403..15f6a05375d0 100644 --- a/tests/components/evil_genius_labs/conftest.py +++ b/tests/components/evil_genius_labs/conftest.py @@ -74,6 +74,6 @@ async def setup_evil_genius_labs( platforms, ), ): - assert await async_setup_component(hass, "evil_genius_labs", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() yield diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index 0ab7686a68bf..b67dde76a6cc 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -115,7 +115,7 @@ async def test_preset_mode_validation( ) setup_test_component_platform(hass, "fan", [test_fan]) - assert await async_setup_component(hass, "fan", {"fan": {"platform": "test"}}) + assert await async_setup_component(hass, DOMAIN, {"fan": {"platform": "test"}}) await hass.async_block_till_done() state = hass.states.get("fan.support_fan_with_preset_mode_support") diff --git a/tests/components/file_upload/test_init.py b/tests/components/file_upload/test_init.py index 22ad9323f05c..2bf68f00ea0f 100644 --- a/tests/components/file_upload/test_init.py +++ b/tests/components/file_upload/test_init.py @@ -9,6 +9,7 @@ from unittest.mock import patch import pytest from homeassistant.components import file_upload +from homeassistant.components.file_upload import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -21,7 +22,7 @@ async def uploaded_file_dir( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> Path: """Test uploading and using a file.""" - assert await async_setup_component(hass, "file_upload", {}) + assert await async_setup_component(hass, DOMAIN, {}) client = await hass_client() with ( @@ -83,7 +84,7 @@ async def test_upload_large_file( hass: HomeAssistant, hass_client: ClientSessionGenerator, large_file_io ) -> None: """Test uploading large file.""" - assert await async_setup_component(hass, "file_upload", {}) + assert await async_setup_component(hass, DOMAIN, {}) client = await hass_client() with ( @@ -117,7 +118,7 @@ async def test_upload_with_wrong_key_fails( hass: HomeAssistant, hass_client: ClientSessionGenerator, large_file_io ) -> None: """Test uploading fails.""" - assert await async_setup_component(hass, "file_upload", {}) + assert await async_setup_component(hass, DOMAIN, {}) client = await hass_client() with patch( @@ -134,7 +135,7 @@ async def test_upload_large_file_fails( hass: HomeAssistant, hass_client: ClientSessionGenerator, large_file_io ) -> None: """Test uploading large file.""" - assert await async_setup_component(hass, "file_upload", {}) + assert await async_setup_component(hass, DOMAIN, {}) client = await hass_client() @contextmanager diff --git a/tests/components/forecast_solar/test_init.py b/tests/components/forecast_solar/test_init.py index 50f87015ad68..980eaa7983ae 100644 --- a/tests/components/forecast_solar/test_init.py +++ b/tests/components/forecast_solar/test_init.py @@ -30,7 +30,7 @@ async def test_load_unload_config_entry( ) -> None: """Test the Forecast.Solar configuration entry loading/unloading.""" mock_config_entry.add_to_hass(hass) - await async_setup_component(hass, "forecast_solar", {}) + await async_setup_component(hass, DOMAIN, {}) assert mock_config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index b09f5c5ddd22..ebe0489424ff 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -85,7 +85,7 @@ async def frontend(hass: HomeAssistant, ignore_frontend_deps: None) -> None: """Frontend setup with themes.""" assert await async_setup_component( hass, - "frontend", + DOMAIN, {}, ) @@ -95,7 +95,7 @@ async def frontend_themes(hass: HomeAssistant) -> None: """Frontend setup with themes.""" assert await async_setup_component( hass, - "frontend", + DOMAIN, CONFIG_THEMES, ) @@ -142,7 +142,7 @@ async def mock_http_client_with_extra_js( """Start the Home Assistant HTTP component.""" assert await async_setup_component( hass, - "frontend", + DOMAIN, { DOMAIN: { CONF_EXTRA_MODULE_URL: ["/local/my_module.js"], @@ -247,7 +247,7 @@ async def test_themes_persist( }, } - assert await async_setup_component(hass, "frontend", CONFIG_THEMES) + assert await async_setup_component(hass, DOMAIN, CONFIG_THEMES) themes_ws_client = await hass_ws_client(hass) await themes_ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) @@ -1117,7 +1117,7 @@ async def test_www_local_dir( await hass.async_add_executor_job(_create_www_and_x_txt) - assert await async_setup_component(hass, "frontend", {}) + assert await async_setup_component(hass, DOMAIN, {}) client = await hass_client() resp = await client.get("/local/x.txt") assert resp.status == HTTPStatus.OK @@ -1338,7 +1338,7 @@ async def test_update_panel_persists( }, } - assert await async_setup_component(hass, "frontend", {}) + assert await async_setup_component(hass, DOMAIN, {}) client = await hass_ws_client(hass) await client.send_json({"id": 1, "type": "get_panels"}) @@ -1452,7 +1452,7 @@ async def test_panels_config_invalid_storage( "data": "not_a_dict", } - assert await async_setup_component(hass, "frontend", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert "Ignoring invalid panel storage data" in caplog.text client = await hass_ws_client(hass) diff --git a/tests/components/frontend/test_storage.py b/tests/components/frontend/test_storage.py index ce015436d5c9..71b2cee5f94c 100644 --- a/tests/components/frontend/test_storage.py +++ b/tests/components/frontend/test_storage.py @@ -19,7 +19,7 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) async def setup_frontend(hass: HomeAssistant) -> None: """Fixture to setup the frontend.""" - await async_setup_component(hass, "frontend", {}) + await async_setup_component(hass, DOMAIN, {}) async def test_get_user_data_empty( diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index c58b6857017e..50e5d35df24a 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -15,6 +15,7 @@ from homeassistant.components import ( light, media_player, ) +from homeassistant.components.google_assistant import DOMAIN from homeassistant.const import EntityCategory, Platform from homeassistant.helpers import entity_registry as er from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -44,7 +45,7 @@ async def assistant_client( """Create web client for the Google Assistant API.""" await setup.async_setup_component( hass, - "google_assistant", + DOMAIN, { "google_assistant": { "project_id": PROJECT_ID, diff --git a/tests/components/google_assistant/test_init.py b/tests/components/google_assistant/test_init.py index 270455d4f76a..7211b89cd12d 100644 --- a/tests/components/google_assistant/test_init.py +++ b/tests/components/google_assistant/test_init.py @@ -3,6 +3,7 @@ from http import HTTPStatus from homeassistant.components import google_assistant as ga +from homeassistant.components.google_assistant import DOMAIN from homeassistant.core import Context, HomeAssistant from homeassistant.setup import async_setup_component @@ -60,7 +61,7 @@ async def test_request_sync_service( await async_setup_component( hass, - "google_assistant", + DOMAIN, {"google_assistant": DUMMY_CONFIG}, ) diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index 0f6ee89075a4..24422feb3246 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -112,9 +112,7 @@ async def mock_init_component( ) -> AsyncGenerator[None]: """Initialize integration.""" with patch("google.genai.models.AsyncModels.get"): - assert await async_setup_component( - hass, "google_generative_ai_conversation", {} - ) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() yield diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 8b190b2db78b..50fcdeb1f5e4 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -8,6 +8,7 @@ from unittest.mock import patch import pytest from homeassistant.components import group +from homeassistant.components.group import DOMAIN from homeassistant.components.group.registry import GroupIntegrationRegistry from homeassistant.components.lock import LockState from homeassistant.const import ( @@ -79,7 +80,7 @@ async def help_test_mixed_entity_platforms_on_off_state_test( if grouped_groups: assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "test1": { @@ -103,7 +104,7 @@ async def help_test_mixed_entity_platforms_on_off_state_test( else: assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "test": { @@ -147,7 +148,7 @@ async def test_setup_group_with_mixed_groupable_states(hass: HomeAssistant) -> N hass.states.async_set("light.Bowl", STATE_ON) hass.states.async_set("device_tracker.Paulus", STATE_HOME) - assert await async_setup_component(hass, "group", {}) + assert await async_setup_component(hass, DOMAIN, {}) await group.Group.async_create_group( hass, @@ -169,7 +170,7 @@ async def test_setup_group_with_a_non_existing_state(hass: HomeAssistant) -> Non """Try to set up a group with a non existing state.""" hass.states.async_set("light.Bowl", STATE_ON) - assert await async_setup_component(hass, "group", {}) + assert await async_setup_component(hass, DOMAIN, {}) grp = await group.Group.async_create_group( hass, @@ -190,7 +191,7 @@ async def test_setup_group_with_non_groupable_states(hass: HomeAssistant) -> Non hass.states.async_set("cast.living_room", "Plex") hass.states.async_set("cast.bedroom", "Netflix") - assert await async_setup_component(hass, "group", {}) + assert await async_setup_component(hass, DOMAIN, {}) grp = await group.Group.async_create_group( hass, @@ -227,7 +228,7 @@ async def test_monitor_group(hass: HomeAssistant) -> None: hass.states.async_set("light.Bowl", STATE_ON) hass.states.async_set("light.Ceiling", STATE_OFF) - assert await async_setup_component(hass, "group", {}) + assert await async_setup_component(hass, DOMAIN, {}) test_group = await group.Group.async_create_group( hass, @@ -253,7 +254,7 @@ async def test_group_turns_off_if_all_off(hass: HomeAssistant) -> None: hass.states.async_set("light.Bowl", STATE_OFF) hass.states.async_set("light.Ceiling", STATE_OFF) - assert await async_setup_component(hass, "group", {}) + assert await async_setup_component(hass, DOMAIN, {}) test_group = await group.Group.async_create_group( hass, @@ -279,7 +280,7 @@ async def test_group_turns_on_if_all_are_off_and_one_turns_on( hass.states.async_set("light.Bowl", STATE_OFF) hass.states.async_set("light.Ceiling", STATE_OFF) - assert await async_setup_component(hass, "group", {}) + assert await async_setup_component(hass, DOMAIN, {}) test_group = await group.Group.async_create_group( hass, @@ -307,7 +308,7 @@ async def test_allgroup_stays_off_if_all_are_off_and_one_turns_on( hass.states.async_set("light.Bowl", STATE_OFF) hass.states.async_set("light.Ceiling", STATE_OFF) - assert await async_setup_component(hass, "group", {}) + assert await async_setup_component(hass, DOMAIN, {}) test_group = await group.Group.async_create_group( hass, @@ -333,7 +334,7 @@ async def test_allgroup_turn_on_if_last_turns_on(hass: HomeAssistant) -> None: hass.states.async_set("light.Bowl", STATE_ON) hass.states.async_set("light.Ceiling", STATE_OFF) - assert await async_setup_component(hass, "group", {}) + assert await async_setup_component(hass, DOMAIN, {}) test_group = await group.Group.async_create_group( hass, @@ -359,7 +360,7 @@ async def test_expand_entity_ids(hass: HomeAssistant) -> None: hass.states.async_set("light.Bowl", STATE_ON) hass.states.async_set("light.Ceiling", STATE_OFF) - assert await async_setup_component(hass, "group", {}) + assert await async_setup_component(hass, DOMAIN, {}) test_group = await group.Group.async_create_group( hass, @@ -384,7 +385,7 @@ async def test_expand_entity_ids_does_not_return_duplicates( hass.states.async_set("light.Bowl", STATE_ON) hass.states.async_set("light.Ceiling", STATE_OFF) - assert await async_setup_component(hass, "group", {}) + assert await async_setup_component(hass, DOMAIN, {}) test_group = await group.Group.async_create_group( hass, @@ -411,7 +412,7 @@ async def test_expand_entity_ids_recursive(hass: HomeAssistant) -> None: hass.states.async_set("light.Bowl", STATE_ON) hass.states.async_set("light.Ceiling", STATE_OFF) - assert await async_setup_component(hass, "group", {}) + assert await async_setup_component(hass, DOMAIN, {}) test_group = await group.Group.async_create_group( hass, @@ -439,7 +440,7 @@ async def test_get_entity_ids(hass: HomeAssistant) -> None: hass.states.async_set("light.Bowl", STATE_ON) hass.states.async_set("light.Ceiling", STATE_OFF) - assert await async_setup_component(hass, "group", {}) + assert await async_setup_component(hass, DOMAIN, {}) test_group = await group.Group.async_create_group( hass, @@ -462,7 +463,7 @@ async def test_get_entity_ids_with_domain_filter(hass: HomeAssistant) -> None: """Test if get_entity_ids works with a domain_filter.""" hass.states.async_set("switch.AC", STATE_OFF) - assert await async_setup_component(hass, "group", {}) + assert await async_setup_component(hass, DOMAIN, {}) mixed_group = await group.Group.async_create_group( hass, @@ -499,7 +500,7 @@ async def test_group_being_init_before_first_tracked_state_is_set_to_on( as ON. """ - assert await async_setup_component(hass, "group", {}) + assert await async_setup_component(hass, DOMAIN, {}) test_group = await group.Group.async_create_group( hass, @@ -528,7 +529,7 @@ async def test_group_being_init_before_first_tracked_state_is_set_to_off( If no states existed and now a state it is tracking is being added as OFF. """ - assert await async_setup_component(hass, "group", {}) + assert await async_setup_component(hass, DOMAIN, {}) test_group = await group.Group.async_create_group( hass, "test group", @@ -551,7 +552,7 @@ async def test_group_being_init_before_first_tracked_state_is_set_to_off( async def test_groups_get_unique_names(hass: HomeAssistant) -> None: """Two groups with same name should both have a unique entity id.""" - assert await async_setup_component(hass, "group", {}) + assert await async_setup_component(hass, DOMAIN, {}) grp1 = await group.Group.async_create_group( hass, @@ -580,7 +581,7 @@ async def test_groups_get_unique_names(hass: HomeAssistant) -> None: async def test_expand_entity_ids_expands_nested_groups(hass: HomeAssistant) -> None: """Test if entity ids epands to nested groups.""" - assert await async_setup_component(hass, "group", {}) + assert await async_setup_component(hass, DOMAIN, {}) await group.Group.async_create_group( hass, @@ -626,7 +627,7 @@ async def test_set_assumed_state_based_on_tracked(hass: HomeAssistant) -> None: hass.states.async_set("light.Bowl", STATE_ON) hass.states.async_set("light.Ceiling", STATE_OFF) - assert await async_setup_component(hass, "group", {}) + assert await async_setup_component(hass, DOMAIN, {}) test_group = await group.Group.async_create_group( hass, @@ -663,7 +664,7 @@ async def test_group_updated_after_device_tracker_zone_change( hass.states.async_set("device_tracker.Eve", STATE_NOT_HOME) await hass.async_block_till_done() - assert await async_setup_component(hass, "group", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert await async_setup_component(hass, "device_tracker", {}) await hass.async_block_till_done() @@ -690,7 +691,7 @@ async def test_is_on(hass: HomeAssistant) -> None: assert group.is_on(hass, "group.none") is False assert await async_setup_component(hass, "light", {}) - assert await async_setup_component(hass, "group", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() test_group = await group.Group.async_create_group( @@ -827,7 +828,7 @@ async def test_is_on_and_state_mixed_domains( await asyncio.gather( *[async_setup_component(hass, domain, {}) for domain in set(domains)] ) - assert await async_setup_component(hass, "group", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() test_group = await group.Group.async_create_group( @@ -862,7 +863,7 @@ async def test_reloading_groups(hass: HomeAssistant) -> None: """Test reloading the group config.""" assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "second_group": {"entities": "light.Bowl", "icon": "mdi:work"}, @@ -919,7 +920,7 @@ async def test_modify_group(hass: HomeAssistant) -> None: "entities": None, } - assert await async_setup_component(hass, "group", {"group": group_conf}) + assert await async_setup_component(hass, DOMAIN, {"group": group_conf}) await hass.async_block_till_done() assert hass.states.get(f"{group.DOMAIN}.modify_group") @@ -947,7 +948,7 @@ async def test_setup(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "light", {}) await hass.async_block_till_done() - assert await async_setup_component(hass, "group", {"group": group_conf}) + assert await async_setup_component(hass, DOMAIN, {"group": group_conf}) await hass.async_block_till_done() test_group = await group.Group.async_create_group( @@ -992,7 +993,7 @@ async def test_setup(hass: HomeAssistant) -> None: async def test_service_group_services(hass: HomeAssistant) -> None: """Check if service are available.""" with assert_setup_component(0, "group"): - await async_setup_component(hass, "group", {"group": {}}) + await async_setup_component(hass, DOMAIN, {"group": {}}) assert hass.services.has_service("group", group.SERVICE_SET) assert hass.services.has_service("group", group.SERVICE_REMOVE) @@ -1007,7 +1008,7 @@ async def test_service_group_services_add_remove_entities(hass: HomeAssistant) - assert await async_setup_component(hass, "person", {}) with assert_setup_component(0, "group"): - await async_setup_component(hass, "group", {"group": {}}) + await async_setup_component(hass, DOMAIN, {"group": {}}) await hass.async_block_till_done() assert hass.services.has_service("group", group.SERVICE_SET) @@ -1058,7 +1059,7 @@ async def test_service_group_services_add_remove_entities(hass: HomeAssistant) - async def test_service_group_set_group_remove_group(hass: HomeAssistant) -> None: """Check if service are available.""" with assert_setup_component(0, "group"): - await async_setup_component(hass, "group", {"group": {}}) + await async_setup_component(hass, DOMAIN, {"group": {}}) common.async_set_group(hass, "user_test_group", name="Test") await hass.async_block_till_done() @@ -1109,7 +1110,7 @@ async def test_group_order(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "light", {}) assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": {"entities": "light.Bowl", "icon": "mdi:work"}, @@ -1132,7 +1133,7 @@ async def test_group_order_with_dynamic_creation(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "light", {}) assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": {"entities": "light.Bowl", "icon": "mdi:work"}, @@ -1186,7 +1187,7 @@ async def test_group_persons(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "person", {}) assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": {"entities": "person.one, person.two, person.three"}, @@ -1209,7 +1210,7 @@ async def test_group_persons_and_device_trackers(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "device_tracker", {}) assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": { @@ -1235,7 +1236,7 @@ async def test_group_mixed_domains_on(hass: HomeAssistant) -> None: assert await async_setup_component(hass, domain, {}) assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": { @@ -1265,7 +1266,7 @@ async def test_group_mixed_domains_off(hass: HomeAssistant) -> None: assert await async_setup_component(hass, domain, {}) assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": { @@ -1303,7 +1304,7 @@ async def test_group_locks(hass: HomeAssistant, states, group_state) -> None: assert await async_setup_component(hass, "lock", {}) assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": {"entities": "lock.one, lock.two, lock.three"}, @@ -1324,7 +1325,7 @@ async def test_group_sensors(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "sensor", {}) assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": {"entities": "sensor.one, sensor.two, sensor.three"}, @@ -1345,7 +1346,7 @@ async def test_group_climate_mixed(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "climate", {}) assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": {"entities": "climate.one, climate.two, climate.three"}, @@ -1366,7 +1367,7 @@ async def test_group_climate_all_cool(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "climate", {}) assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": {"entities": "climate.one, climate.two, climate.three"}, @@ -1387,7 +1388,7 @@ async def test_group_climate_all_off(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "climate", {}) assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": {"entities": "climate.one, climate.two, climate.three"}, @@ -1408,7 +1409,7 @@ async def test_group_alarm(hass: HomeAssistant) -> None: assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": { @@ -1437,7 +1438,7 @@ async def test_group_alarm_disarmed(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "alarm_control_panel", {}) assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": { @@ -1464,7 +1465,7 @@ async def test_group_vacuum_off(hass: HomeAssistant) -> None: assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": {"entities": "vacuum.one, vacuum.two, vacuum.three"}, @@ -1488,7 +1489,7 @@ async def test_group_vacuum_on(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "vacuum", {}) assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": {"entities": "vacuum.one, vacuum.two, vacuum.three"}, @@ -1551,7 +1552,7 @@ async def test_device_tracker_or_person_not_home( assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": {"entities": ", ".join(entity_state_list)}, @@ -1571,7 +1572,7 @@ async def test_light_removed(hass: HomeAssistant) -> None: assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": {"entities": "light.one, light.two, light.three"}, @@ -1597,7 +1598,7 @@ async def test_switch_removed(hass: HomeAssistant) -> None: hass.set_state(CoreState.stopped) assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": {"entities": "switch.one, switch.two, switch.three"}, @@ -1634,7 +1635,7 @@ async def test_lights_added_after_group(hass: HomeAssistant) -> None: assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "living_room_downlights": {"entities": entity_ids}, @@ -1670,7 +1671,7 @@ async def test_lights_added_before_group(hass: HomeAssistant) -> None: assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "living_room_downlights": {"entities": entity_ids}, @@ -1693,7 +1694,7 @@ async def test_cover_added_after_group(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "cover", {}) assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "shades": {"entities": entity_ids}, @@ -1731,7 +1732,7 @@ async def test_group_that_references_a_group_of_lights(hass: HomeAssistant) -> N assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "living_room_downlights": {"entities": entity_ids}, @@ -1766,7 +1767,7 @@ async def test_group_that_references_a_group_of_covers(hass: HomeAssistant) -> N assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "living_room_downcover": {"entities": entity_ids}, @@ -1803,7 +1804,7 @@ async def test_group_that_references_two_groups_of_covers(hass: HomeAssistant) - assert await async_setup_component(hass, "cover", {}) assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "living_room_downcover": {"entities": entity_ids}, @@ -1850,7 +1851,7 @@ async def test_group_that_references_two_types_of_groups(hass: HomeAssistant) -> assert await async_setup_component(hass, "device_tracker", {}) assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "covers": {"entities": group_1_entity_ids}, @@ -1905,7 +1906,7 @@ async def test_plant_group(hass: HomeAssistant) -> None: ) assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "plants": {"entities": entity_ids}, diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index 069c10f5c912..227a7e48f075 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -1565,7 +1565,7 @@ async def test_reload_with_platform_not_setup(hass: HomeAssistant) -> None: ) assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": {"entities": "light.Bowl", "icon": "mdi:work"}, @@ -1595,7 +1595,7 @@ async def test_reload_with_base_integration_platform_not_setup( """Test the ability to reload lights.""" assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": {"entities": "light.Bowl", "icon": "mdi:work"}, diff --git a/tests/components/group/test_lock.py b/tests/components/group/test_lock.py index fda8ce7f19a8..15834375bf67 100644 --- a/tests/components/group/test_lock.py +++ b/tests/components/group/test_lock.py @@ -331,7 +331,7 @@ async def test_reload_with_platform_not_setup(hass: HomeAssistant) -> None: ) assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": {"entities": "lock.something", "icon": "mdi:work"}, @@ -361,7 +361,7 @@ async def test_reload_with_base_integration_platform_not_setup( """Test the ability to reload locks.""" assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": {"entities": "lock.something", "icon": "mdi:work"}, diff --git a/tests/components/group/test_notify.py b/tests/components/group/test_notify.py index 58016f8c859d..b79ae08556f7 100644 --- a/tests/components/group/test_notify.py +++ b/tests/components/group/test_notify.py @@ -111,7 +111,7 @@ async def test_send_message_with_data(hass: HomeAssistant, tmp_path: Path) -> No """Test sending a message with to a notify group.""" assert await async_setup_component( hass, - "group", + DOMAIN, {}, ) await hass.async_block_till_done() @@ -211,7 +211,7 @@ async def test_invalid_configuration( """Test failing to set up group with an invalid configuration.""" assert await async_setup_component( hass, - "group", + DOMAIN, {}, ) await hass.async_block_till_done() @@ -244,7 +244,7 @@ async def test_reload_notify(hass: HomeAssistant, tmp_path: Path) -> None: """Verify we can reload the notify service.""" assert await async_setup_component( hass, - "group", + DOMAIN, {}, ) await hass.async_block_till_done() diff --git a/tests/components/group/test_switch.py b/tests/components/group/test_switch.py index 674a319644f6..c2bf0d7d37b4 100644 --- a/tests/components/group/test_switch.py +++ b/tests/components/group/test_switch.py @@ -349,7 +349,7 @@ async def test_reload_with_platform_not_setup(hass: HomeAssistant) -> None: ) assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": {"entities": "switch.something", "icon": "mdi:work"}, @@ -379,7 +379,7 @@ async def test_reload_with_base_integration_platform_not_setup( """Test the ability to reload switches.""" assert await async_setup_component( hass, - "group", + DOMAIN, { "group": { "group_zero": {"entities": "switch.something", "icon": "mdi:work"}, From 676a8c39eb62d1dc36b7b9dc56784a77080b56b2 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Mon, 8 Jun 2026 10:16:42 -0400 Subject: [PATCH 012/404] Environment Canada integration: add get_alerts action (#172393) --- .../components/environment_canada/__init__.py | 13 ++++- .../components/environment_canada/icons.json | 3 + .../components/environment_canada/services.py | 56 +++++++++++++++++++ .../environment_canada/services.yaml | 8 +++ .../environment_canada/strings.json | 15 +++++ .../fixtures/current_conditions_data.json | 52 +++++++++++------ .../snapshots/test_services.ambr | 39 +++++++++++++ .../environment_canada/test_services.py | 47 ++++++++++++++++ 8 files changed, 216 insertions(+), 17 deletions(-) create mode 100644 homeassistant/components/environment_canada/services.py create mode 100644 tests/components/environment_canada/snapshots/test_services.ambr create mode 100644 tests/components/environment_canada/test_services.py diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index 8a5f47beaf63..fc9733a4e375 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -8,9 +8,12 @@ from env_canada import ECAirQuality, ECMap, ECWeather from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType -from .const import CONF_STATION +from .const import CONF_STATION, DOMAIN from .coordinator import ECConfigEntry, ECDataUpdateCoordinator, ECRuntimeData +from .services import async_setup_services DEFAULT_RADAR_UPDATE_INTERVAL = timedelta(minutes=5) DEFAULT_WEATHER_UPDATE_INTERVAL = timedelta(minutes=5) @@ -19,6 +22,14 @@ PLATFORMS = [Platform.CAMERA, Platform.SENSOR, Platform.WEATHER] _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Environment Canada services.""" + async_setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) -> bool: """Set up EC as config entry.""" diff --git a/homeassistant/components/environment_canada/icons.json b/homeassistant/components/environment_canada/icons.json index 035f5ff661df..691485b34afa 100644 --- a/homeassistant/components/environment_canada/icons.json +++ b/homeassistant/components/environment_canada/icons.json @@ -19,6 +19,9 @@ } }, "services": { + "get_alerts": { + "service": "mdi:bell-alert" + }, "get_forecasts": { "service": "mdi:weather-cloudy-clock" }, diff --git a/homeassistant/components/environment_canada/services.py b/homeassistant/components/environment_canada/services.py new file mode 100644 index 000000000000..23e8b3b8ffea --- /dev/null +++ b/homeassistant/components/environment_canada/services.py @@ -0,0 +1,56 @@ +"""Define services for the Environment Canada integration.""" + +from typing import Any + +from env_canada import ECWeather +import voluptuous as vol + +from homeassistant.const import ATTR_CONFIG_ENTRY_ID +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, service + +from .const import DOMAIN + +SERVICE_GET_ALERTS = "get_alerts" +SERVICE_GET_ALERTS_SCHEMA = vol.Schema({vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string}) + +SNAKE_MAPPING = { + "alertColourLevel": "alert_colour_level", + "expiryTime": "expiry_time", +} + + +async def _async_get_alerts(call: ServiceCall) -> dict[str, Any]: + """Return the active alerts.""" + entry = service.async_get_config_entry( + call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] + ) + + ec: ECWeather | None = entry.runtime_data.weather_coordinator.ec_data + if ec is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_connected", + ) + + data: dict[str, Any] = ec.alerts + return { + k: [ + {SNAKE_MAPPING.get(ik, ik): iv for ik, iv in item.items()} + for item in v["value"] + ] + for k, v in data.items() + } + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Environment Canada integration.""" + hass.services.async_register( + DOMAIN, + SERVICE_GET_ALERTS, + _async_get_alerts, + schema=SERVICE_GET_ALERTS_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/environment_canada/services.yaml b/homeassistant/components/environment_canada/services.yaml index 7999d14eefb3..afa5ed49abca 100644 --- a/homeassistant/components/environment_canada/services.yaml +++ b/homeassistant/components/environment_canada/services.yaml @@ -1,3 +1,11 @@ +get_alerts: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: environment_canada + get_forecasts: target: entity: diff --git a/homeassistant/components/environment_canada/strings.json b/homeassistant/components/environment_canada/strings.json index 30cb7c254b92..1f159bf9c997 100644 --- a/homeassistant/components/environment_canada/strings.json +++ b/homeassistant/components/environment_canada/strings.json @@ -112,7 +112,22 @@ } } }, + "exceptions": { + "not_connected": { + "message": "Environment Canada is not connected" + } + }, "services": { + "get_alerts": { + "description": "Retrieves the alerts from the selected weather service.", + "fields": { + "config_entry_id": { + "description": "The Environment Canada service to retrieve alerts from.", + "name": "Environment Canada service" + } + }, + "name": "Get alerts" + }, "get_forecasts": { "description": "Retrieves the forecast from selected weather services.", "name": "Get forecasts" diff --git a/tests/components/environment_canada/fixtures/current_conditions_data.json b/tests/components/environment_canada/fixtures/current_conditions_data.json index e3b9563ef0b9..727b4371a003 100644 --- a/tests/components/environment_canada/fixtures/current_conditions_data.json +++ b/tests/components/environment_canada/fixtures/current_conditions_data.json @@ -1,29 +1,49 @@ { "alerts": { "warnings": { - "value": [], - "label": "Warnings" - }, - "watches": { - "value": [], - "label": "Watches" - }, - "advisories": { + "label": "Warnings", "value": [ { - "title": "Frost Advisory", - "date": "Monday October 03, 2022 at 15:05 EDT" + "title": "Air Quality Warning", + "date": "2026-06-04T10:35:16.386Z", + "alertColourLevel": "yellow", + "expiryTime": "2026-06-04T19:00:16.386Z", + "text": "Wildfire smoke is causing poor air quality. Conditions are expected to improve later this morning. Air quality and visibility due to wildfire smoke can fluctuate over short distances and can vary considerably from hour to hour. As smoke levels increase, health risks increase. Limit time outdoors. Consider reducing or rescheduling outdoor sports, activities and events.", + "area": "Yellowknife Region", + "status": "continued", + "confidence": "High", + "impact": "Moderate", + "alert_code": "AQW" + }, + { + "title": "Wow, it is hot out there!", + "date": "2026-06-04T10:35:16.386Z", + "alertColourLevel": "red", + "expiryTime": "2026-06-04T19:00:16.386Z", + "text": "It is so hot you can fry an egg on the pavement!", + "area": "Yellowknife Region", + "status": "continued", + "confidence": "High", + "impact": "Moderate", + "alert_code": "HOT" } - ], - "label": "Advisories" + ] + }, + "watches": { + "label": "Watches", + "value": [] + }, + "advisories": { + "label": "Advisories", + "value": [] }, "statements": { - "value": [], - "label": "Statements" + "label": "Statements", + "value": [] }, "endings": { - "value": [], - "label": "Endings" + "label": "Endings", + "value": [] } }, "conditions": { diff --git a/tests/components/environment_canada/snapshots/test_services.ambr b/tests/components/environment_canada/snapshots/test_services.ambr new file mode 100644 index 000000000000..259b8a5a9687 --- /dev/null +++ b/tests/components/environment_canada/snapshots/test_services.ambr @@ -0,0 +1,39 @@ +# serializer version: 1 +# name: test_get_alerts + dict({ + 'advisories': list([ + ]), + 'endings': list([ + ]), + 'statements': list([ + ]), + 'warnings': list([ + dict({ + 'alert_code': 'AQW', + 'alert_colour_level': 'yellow', + 'area': 'Yellowknife Region', + 'confidence': 'High', + 'date': '2026-06-04T10:35:16.386Z', + 'expiry_time': '2026-06-04T19:00:16.386Z', + 'impact': 'Moderate', + 'status': 'continued', + 'text': 'Wildfire smoke is causing poor air quality. Conditions are expected to improve later this morning. Air quality and visibility due to wildfire smoke can fluctuate over short distances and can vary considerably from hour to hour. As smoke levels increase, health risks increase. Limit time outdoors. Consider reducing or rescheduling outdoor sports, activities and events.', + 'title': 'Air Quality Warning', + }), + dict({ + 'alert_code': 'HOT', + 'alert_colour_level': 'red', + 'area': 'Yellowknife Region', + 'confidence': 'High', + 'date': '2026-06-04T10:35:16.386Z', + 'expiry_time': '2026-06-04T19:00:16.386Z', + 'impact': 'Moderate', + 'status': 'continued', + 'text': 'It is so hot you can fry an egg on the pavement!', + 'title': 'Wow, it is hot out there!', + }), + ]), + 'watches': list([ + ]), + }) +# --- diff --git a/tests/components/environment_canada/test_services.py b/tests/components/environment_canada/test_services.py new file mode 100644 index 000000000000..4e0df908d180 --- /dev/null +++ b/tests/components/environment_canada/test_services.py @@ -0,0 +1,47 @@ +"""Tests for the Environment Canada services.""" + +from typing import Any + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.environment_canada.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import init_integration + +SERVICE_GET_ALERTS = "get_alerts" + + +async def test_get_alerts( + hass: HomeAssistant, snapshot: SnapshotAssertion, ec_data: dict[str, Any] +) -> None: + """Test the get_alerts service returns active alerts.""" + config_entry = await init_integration(hass, ec_data) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_ALERTS, + {"config_entry_id": config_entry.entry_id}, + blocking=True, + return_response=True, + ) + assert response == snapshot + + +async def test_get_alerts_not_connected( + hass: HomeAssistant, ec_data: dict[str, Any] +) -> None: + """Test get_alerts raises when weather data is not connected.""" + config_entry = await init_integration(hass, ec_data) + config_entry.runtime_data.weather_coordinator.ec_data = None + + with pytest.raises(HomeAssistantError, match="not connected"): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_ALERTS, + {"config_entry_id": config_entry.entry_id}, + blocking=True, + return_response=True, + ) From 397c28b9b6ac84268f566a324a5b42da9267c1a3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:28:17 +0200 Subject: [PATCH 013/404] Use DOMAIN constant in test (async_setup_component h-n) (#173015) --- tests/components/hassio/test_addon_panel.py | 13 ++-- tests/components/hassio/test_binary_sensor.py | 6 +- tests/components/hassio/test_config.py | 4 +- tests/components/hassio/test_diagnostics.py | 2 +- tests/components/hassio/test_discovery.py | 3 +- tests/components/hassio/test_init.py | 66 +++++++++--------- tests/components/hassio/test_issues.py | 46 ++++++------- tests/components/hassio/test_jobs.py | 10 +-- tests/components/hassio/test_repairs.py | 29 ++++---- tests/components/hassio/test_sensor.py | 4 +- tests/components/hassio/test_switch.py | 2 +- tests/components/hassio/test_update.py | 68 +++++++++---------- tests/components/hassio/test_websocket_api.py | 34 +++++----- tests/components/history/test_init.py | 31 +++++---- .../components/history/test_websocket_api.py | 52 +++++++------- .../homeassistant/test_exposed_entities.py | 21 +++--- tests/components/homeassistant/test_init.py | 30 ++++---- .../homeassistant_sky_connect/test_init.py | 2 +- tests/components/homekit/test_config_flow.py | 4 +- tests/components/homekit/test_homekit.py | 8 +-- tests/components/homekit/test_init.py | 2 +- tests/components/homematic/test_notify.py | 5 +- tests/components/http/test_auth.py | 3 +- tests/components/http/test_ban.py | 5 +- tests/components/http/test_cors.py | 8 +-- tests/components/http/test_init.py | 43 ++++++------ tests/components/http/test_static.py | 4 +- tests/components/hue/test_init.py | 3 +- tests/components/image_upload/test_init.py | 3 +- .../image_upload/test_media_source.py | 3 +- tests/components/input_boolean/test_init.py | 2 +- .../input_boolean/test_reproduce_state.py | 3 +- tests/components/input_datetime/test_init.py | 2 +- tests/components/input_number/test_init.py | 2 +- .../input_number/test_reproduce_state.py | 3 +- tests/components/input_select/test_init.py | 2 +- .../input_select/test_reproduce_state.py | 3 +- tests/components/input_text/test_init.py | 2 +- .../input_text/test_reproduce_state.py | 3 +- tests/components/intent/test_init.py | 43 ++++++------ tests/components/intent/test_temperature.py | 7 +- tests/components/intent/test_timers.py | 5 +- tests/components/intent_script/test_init.py | 16 ++--- tests/components/iotawatt/test_init.py | 7 +- tests/components/iotawatt/test_sensor.py | 5 +- tests/components/light/test_init.py | 31 +++++---- tests/components/logbook/test_init.py | 59 ++++++++-------- .../components/logbook/test_websocket_api.py | 14 ++-- tests/components/logger/test_init.py | 18 ++--- tests/components/logger/test_websocket_api.py | 21 +++--- tests/components/lovelace/test_cast.py | 6 +- tests/components/lovelace/test_dashboard.py | 36 +++++----- tests/components/lovelace/test_init.py | 6 +- tests/components/lovelace/test_resources.py | 18 +++-- .../components/lovelace/test_system_health.py | 10 +-- tests/components/lutron/test_init.py | 6 +- tests/components/media_player/test_init.py | 49 ++++--------- tests/components/melissa/__init__.py | 3 +- tests/components/mill/test_init.py | 11 +-- tests/components/my/test_init.py | 4 +- tests/components/netatmo/test_diagnostics.py | 3 +- tests/components/netatmo/test_init.py | 16 ++--- tests/components/network/test_init.py | 8 +-- tests/components/notify/test_legacy.py | 18 ++--- tests/components/numato/test_binary_sensor.py | 9 +-- tests/components/numato/test_init.py | 23 ++++--- tests/components/numato/test_sensor.py | 5 +- tests/components/numato/test_switch.py | 7 +- tests/components/number/test_init.py | 14 ++-- 69 files changed, 508 insertions(+), 506 deletions(-) diff --git a/tests/components/hassio/test_addon_panel.py b/tests/components/hassio/test_addon_panel.py index 38f9d9b690cf..ca5fde3bc58a 100644 --- a/tests/components/hassio/test_addon_panel.py +++ b/tests/components/hassio/test_addon_panel.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch from aiohasupervisor.models import IngressPanel import pytest +from homeassistant.components.hassio import DOMAIN from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -38,7 +39,7 @@ async def test_hassio_addon_panel_startup( "homeassistant.components.hassio.addon_panel._register_panel", ) as mock_panel: with patch.dict(os.environ, MOCK_ENVIRON): - await async_setup_component(hass, "hassio", {}) + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() ingress_panels.assert_not_called() @@ -71,7 +72,7 @@ async def test_hassio_addon_panel_api( } with patch.dict(os.environ, MOCK_ENVIRON): - await async_setup_component(hass, "hassio", {}) + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() with patch( @@ -119,7 +120,7 @@ async def test_hassio_addon_panel_api_non_admin( } with patch.dict(os.environ, MOCK_ENVIRON): - await async_setup_component(hass, "hassio", {}) + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() with patch( @@ -160,7 +161,7 @@ async def test_hassio_addon_panel_registration( } with patch.dict(os.environ, MOCK_ENVIRON): - await async_setup_component(hass, "hassio", {}) + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() with patch( @@ -193,7 +194,7 @@ async def test_hassio_addon_panel_api_delete( "test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), } with patch.dict(os.environ, MOCK_ENVIRON): - await async_setup_component(hass, "hassio", {}) + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() hass_client = await hass_client() @@ -218,7 +219,7 @@ async def test_hassio_addon_panel_api_delete_non_admin( "test1": IngressPanel(enable=True, title="Test", icon="mdi:test", admin=False), } with patch.dict(os.environ, MOCK_ENVIRON): - await async_setup_component(hass, "hassio", {}) + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() hass_admin_user.groups = [] diff --git a/tests/components/hassio/test_binary_sensor.py b/tests/components/hassio/test_binary_sensor.py index 9211ba6cde24..c4a0d6c0e859 100644 --- a/tests/components/hassio/test_binary_sensor.py +++ b/tests/components/hassio/test_binary_sensor.py @@ -109,7 +109,7 @@ async def test_binary_sensor( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -140,7 +140,7 @@ async def test_mount_binary_sensor( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -236,7 +236,7 @@ async def test_mount_refresh_after_issue( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result diff --git a/tests/components/hassio/test_config.py b/tests/components/hassio/test_config.py index 4521aa30abdc..cb06ae2673b6 100644 --- a/tests/components/hassio/test_config.py +++ b/tests/components/hassio/test_config.py @@ -114,7 +114,7 @@ async def test_load_config_store( await hass.auth.async_update_user(user, group_ids=[GROUP_ID_ADMIN]) with patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0): - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() await hass.async_block_till_done() @@ -131,7 +131,7 @@ async def test_save_config_store( ) -> None: """Test saving the config store.""" with patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0): - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() await hass.async_block_till_done() diff --git a/tests/components/hassio/test_diagnostics.py b/tests/components/hassio/test_diagnostics.py index 9e2fd2051470..0c1b1d2399dc 100644 --- a/tests/components/hassio/test_diagnostics.py +++ b/tests/components/hassio/test_diagnostics.py @@ -97,7 +97,7 @@ async def test_diagnostics( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result diff --git a/tests/components/hassio/test_discovery.py b/tests/components/hassio/test_discovery.py index 70573a1e57e5..d25ecc77d917 100644 --- a/tests/components/hassio/test_discovery.py +++ b/tests/components/hassio/test_discovery.py @@ -11,6 +11,7 @@ from aiohttp.test_utils import TestClient import pytest from homeassistant import config_entries +from homeassistant.components.hassio import DOMAIN from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN from homeassistant.config_entries import ConfigEntries from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED @@ -124,7 +125,7 @@ async def test_hassio_discovery_startup_done( supervisor_root_info.side_effect = SupervisorError() await hass.async_start() - await async_setup_component(hass, "hassio", {}) + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert get_addon_discovery_info.call_count == 1 diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 93bbdc0f0296..281ebd9fe8b9 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -165,7 +165,7 @@ async def test_setup_api_ping( ) -> None: """Test setup with API ping.""" with patch.dict(os.environ, MOCK_ENVIRON): - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert result @@ -181,7 +181,7 @@ async def test_setup_api_ping_fails( supervisor_client.supervisor.ping.side_effect = SupervisorError with patch.dict(os.environ, MOCK_ENVIRON): - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() # async_setup succeeds (domain registered), but the config entry is in retry @@ -200,7 +200,7 @@ async def test_setup_onboarding_supervisor_update( patch.dict(os.environ, MOCK_ENVIRON), patch("homeassistant.components.hassio.async_is_onboarded", return_value=False), ): - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert result @@ -221,7 +221,7 @@ async def test_setup_onboarding_supervisor_no_update( patch.dict(os.environ, MOCK_ENVIRON), patch("homeassistant.components.hassio.async_is_onboarded", return_value=False), ): - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert result @@ -242,7 +242,7 @@ async def test_setup_onboarding_supervisor_update_error( patch.dict(os.environ, MOCK_ENVIRON), patch("homeassistant.components.hassio.async_is_onboarded", return_value=False), ): - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert result @@ -255,7 +255,7 @@ async def test_setup_onboarding_supervisor_update_error( async def test_setup_app_panel(hass: HomeAssistant) -> None: """Test app panel is registered.""" with patch.dict(os.environ, MOCK_ENVIRON): - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert result @@ -280,7 +280,7 @@ async def test_setup_api_push_api_data( """Test setup with API push.""" with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( - hass, "hassio", {"http": {"server_port": 9999}, "hassio": {}} + hass, DOMAIN, {"http": {"server_port": 9999}, "hassio": {}} ) await hass.async_block_till_done() @@ -297,7 +297,7 @@ async def test_setup_api_push_api_data_error( """Test setup with error while pushing core config data to API.""" supervisor_client.homeassistant.set_options.side_effect = SupervisorError("boom") with patch.dict(os.environ, MOCK_ENVIRON): - result = await async_setup_component(hass, "hassio", {"http": {}, "hassio": {}}) + result = await async_setup_component(hass, DOMAIN, {"http": {}, "hassio": {}}) await hass.async_block_till_done() assert result @@ -312,7 +312,7 @@ async def test_setup_api_push_api_data_server_host( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) await hass.async_block_till_done() @@ -332,7 +332,7 @@ async def test_setup_api_push_api_data_default( patch.dict(os.environ, MOCK_ENVIRON), patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0), ): - result = await async_setup_component(hass, "hassio", {"http": {}, "hassio": {}}) + result = await async_setup_component(hass, DOMAIN, {"http": {}, "hassio": {}}) await hass.async_block_till_done() assert result @@ -374,7 +374,7 @@ async def test_setup_adds_admin_group_to_user( } with patch.dict(os.environ, MOCK_ENVIRON): - result = await async_setup_component(hass, "hassio", {"http": {}, "hassio": {}}) + result = await async_setup_component(hass, DOMAIN, {"http": {}, "hassio": {}}) assert result assert user.is_admin @@ -395,7 +395,7 @@ async def test_setup_migrate_user_name( } with patch.dict(os.environ, MOCK_ENVIRON): - result = await async_setup_component(hass, "hassio", {"http": {}, "hassio": {}}) + result = await async_setup_component(hass, DOMAIN, {"http": {}, "hassio": {}}) assert result assert user.name == "Supervisor" @@ -409,7 +409,7 @@ async def test_setup_api_existing_hassio_user( token = await hass.auth.async_create_refresh_token(user) hass_storage[STORAGE_KEY] = {"version": 1, "data": {"hassio_user": user.id}} with patch.dict(os.environ, MOCK_ENVIRON): - result = await async_setup_component(hass, "hassio", {"http": {}, "hassio": {}}) + result = await async_setup_component(hass, DOMAIN, {"http": {}, "hassio": {}}) await hass.async_block_till_done() assert result @@ -426,7 +426,7 @@ async def test_setup_core_push_config( hass.config.time_zone = "testzone" with patch.dict(os.environ, MOCK_ENVIRON): - result = await async_setup_component(hass, "hassio", {"hassio": {}}) + result = await async_setup_component(hass, DOMAIN, {"hassio": {}}) await hass.async_block_till_done() assert result @@ -451,7 +451,7 @@ async def test_setup_core_push_config_error( supervisor_client.supervisor.set_options.side_effect = SupervisorError("boom") with patch.dict(os.environ, MOCK_ENVIRON): - result = await async_setup_component(hass, "hassio", {"hassio": {}}) + result = await async_setup_component(hass, DOMAIN, {"hassio": {}}) await hass.async_block_till_done() assert result @@ -467,7 +467,7 @@ async def test_setup_hassio_no_additional_data( patch.dict(os.environ, MOCK_ENVIRON), patch.dict(os.environ, {"SUPERVISOR_TOKEN": "123456"}), ): - result = await async_setup_component(hass, "hassio", {"hassio": {}}) + result = await async_setup_component(hass, DOMAIN, {"hassio": {}}) await hass.async_block_till_done() assert result @@ -477,7 +477,7 @@ async def test_setup_hassio_no_additional_data( async def test_fail_setup_without_environ_var(hass: HomeAssistant) -> None: """Fail setup if no environ variable set.""" with patch.dict(os.environ, {}, clear=True): - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert not result @@ -488,7 +488,7 @@ async def test_warn_when_cannot_connect( """Test that a failed ping puts the config entry in retry state.""" supervisor_is_connected.side_effect = SupervisorError with patch.dict(os.environ, MOCK_ENVIRON): - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result assert is_hassio(hass) @@ -499,7 +499,7 @@ async def test_warn_when_cannot_connect( @pytest.mark.usefixtures("hassio_env") async def test_service_register(hass: HomeAssistant) -> None: """Check if service will be setup.""" - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) # New app services assert hass.services.has_service("hassio", "app_start") assert hass.services.has_service("hassio", "app_stop") @@ -535,7 +535,7 @@ async def test_service_calls( """Call service and check the API calls behind that.""" supervisor_is_connected.side_effect = SupervisorError with patch.dict(os.environ, MOCK_ENVIRON): - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() supervisor_client.reset_mock() @@ -694,7 +694,7 @@ async def test_service_calls( async def test_invalid_service_calls(hass: HomeAssistant, app_or_addon: str) -> None: """Call service with invalid input and check that it raises.""" with patch.dict(os.environ, MOCK_ENVIRON): - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() with pytest.raises(Invalid): @@ -732,7 +732,7 @@ async def test_service_calls_apps_addons_exclusive( """Test that apps and addons parameters are mutually exclusive.""" supervisor_is_connected.side_effect = SupervisorError with patch.dict(os.environ, MOCK_ENVIRON): - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() with pytest.raises( @@ -775,7 +775,7 @@ async def test_addon_service_call_with_complex_slug( ] supervisor_is_connected.side_effect = SupervisorError with patch.dict(os.environ, MOCK_ENVIRON): - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() await hass.services.async_call( @@ -790,7 +790,7 @@ async def test_service_calls_core( """Call core service and check the API calls behind that.""" with patch.dict(os.environ, MOCK_ENVIRON): assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.services.async_call("homeassistant", "stop") await hass.async_block_till_done() @@ -819,7 +819,7 @@ async def test_invalid_service_calls_app_duplicates( hass: HomeAssistant, app_or_addon: str ) -> None: """Test invalid backup/restore service calls due to duplicates in apps list.""" - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) with pytest.raises(Invalid, match="contains duplicate items"): await hass.services.async_call( @@ -835,7 +835,7 @@ async def test_invalid_service_calls_app_duplicates( @pytest.mark.usefixtures("hassio_env", "supervisor_client") async def test_invalid_service_calls_folder_duplicates(hass: HomeAssistant) -> None: """Test invalid backup/restore service calls due to duplicates in folder list.""" - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) with pytest.raises(Invalid, match="contains duplicate items"): await hass.services.async_call( @@ -853,7 +853,7 @@ async def test_partial_backup_legacy_homeassistant_folder( hass: HomeAssistant, supervisor_client: AsyncMock ) -> None: """Test legacy "homeassistant" folder is translated to homeassistant=True.""" - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) supervisor_client.backups.partial_backup.return_value = NewBackup( job_id=uuid4(), slug="partial" ) @@ -883,7 +883,7 @@ async def test_partial_restore_legacy_homeassistant_folder( hass: HomeAssistant, supervisor_client: AsyncMock ) -> None: """Test that the legacy "homeassistant" folder is translated for restore too.""" - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.services.async_call( DOMAIN, @@ -903,7 +903,7 @@ async def test_partial_restore_legacy_homeassistant_folder( @pytest.mark.usefixtures("hassio_env", "supervisor_client") async def test_partial_backup_invalid_folder(hass: HomeAssistant) -> None: """Test that an unknown folder name is rejected.""" - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) with pytest.raises(Invalid, match="not a valid value"): await hass.services.async_call(DOMAIN, "backup_partial", {"folders": ["bogus"]}) @@ -914,7 +914,7 @@ async def test_partial_backup_legacy_homeassistant_folder_conflict( hass: HomeAssistant, ) -> None: """Reject combining homeassistant=False with the legacy "homeassistant" folder.""" - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) with pytest.raises(ServiceValidationError, match="conflicts"): await hass.services.async_call( @@ -1198,7 +1198,7 @@ async def test_setup_hardware_integration( return_value=None, ), ): - result = await async_setup_component(hass, "hassio", {"hassio": {}}) + result = await async_setup_component(hass, DOMAIN, {"hassio": {}}) await hass.async_block_till_done(wait_background_tasks=True) assert result @@ -1895,7 +1895,7 @@ async def test_stop_handler_restored_on_unload( """Test that the default stop handler is restored when the hassio entry unloads.""" assert await async_setup_component(hass, "homeassistant", {}) with patch.dict(os.environ, MOCK_ENVIRON): - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() entry = hass.config_entries.async_entries("hassio")[0] @@ -1922,7 +1922,7 @@ async def test_supervisor_issues_not_set_on_coordinator_failure( """ supervisor_root_info.side_effect = SupervisorError() with patch.dict(os.environ, MOCK_ENVIRON): - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result entry = hass.config_entries.async_entries("hassio")[0] diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index 8516ec3798df..2da8b47eb394 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -150,7 +150,7 @@ async def test_unhealthy_issues( supervisor_client, unhealthy=[UnhealthyReason.DOCKER, UnhealthyReason.SETUP] ) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_ws_client(hass) @@ -174,7 +174,7 @@ async def test_unhealthy_reasons( """Test all unhealthy reasons in client library are made into repairs.""" mock_resolution_info(supervisor_client, unhealthy=[unhealthy_reason]) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_ws_client(hass) @@ -200,7 +200,7 @@ async def test_unsupported_issues( unsupported=[UnsupportedReason.CONNECTIVITY_CHECK, UnsupportedReason.OS], ) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_ws_client(hass) @@ -229,7 +229,7 @@ async def test_unsupported_reasons( """Test all unsupported reasons in client library are made into repairs.""" mock_resolution_info(supervisor_client, unsupported=[unsupported_reason]) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_ws_client(hass) @@ -252,7 +252,7 @@ async def test_unhealthy_issues_add_remove( """Test unhealthy issues added and removed from dispatches.""" mock_resolution_info(supervisor_client) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_supervisor_ws_client() @@ -309,7 +309,7 @@ async def test_unsupported_issues_add_remove( """Test unsupported issues added and removed from dispatches.""" mock_resolution_info(supervisor_client) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_supervisor_ws_client() @@ -389,7 +389,7 @@ async def test_reset_issues_supervisor_restart( }, ) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_supervisor_ws_client() @@ -443,7 +443,7 @@ async def test_no_reset_issues_supervisor_update_found( unsupported=[UnsupportedReason.OS], ) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_supervisor_ws_client() @@ -488,7 +488,7 @@ async def test_reasons_added_and_removed( unhealthy=[UnhealthyReason.DOCKER], ) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_supervisor_ws_client() @@ -543,7 +543,7 @@ async def test_ignored_unsupported_skipped( unhealthy=[UnhealthyReason.PRIVILEGED], ) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_ws_client(hass) @@ -568,7 +568,7 @@ async def test_new_unsupported_unhealthy_reason( unhealthy=["fake_unhealthy"], ) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_ws_client(hass) @@ -649,7 +649,7 @@ async def test_supervisor_issues( }, ) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_ws_client(hass) @@ -716,7 +716,7 @@ async def test_supervisor_issues_initial_failure( ] with patch("homeassistant.components.hassio.issues.REQUEST_REFRESH_DELAY", new=0.1): - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert result @@ -744,7 +744,7 @@ async def test_supervisor_issues_add_remove( """Test supervisor issues added and removed from dispatches.""" mock_resolution_info(supervisor_client) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_supervisor_ws_client() @@ -835,7 +835,7 @@ async def test_supervisor_issues_suggestions_fail( ) resolution_suggestions_for_issue.side_effect = SupervisorTimeoutError - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_ws_client(hass) @@ -855,7 +855,7 @@ async def test_supervisor_remove_missing_issue_without_error( """Test HA skips message to remove issue that it didn't know about (sync issue).""" mock_resolution_info(supervisor_client) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_supervisor_ws_client() @@ -891,7 +891,7 @@ async def test_system_is_not_ready( "System is not ready with state: setup" ) - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert "Failed to update supervisor issues" in caplog.text @@ -907,7 +907,7 @@ async def test_supervisor_issues_detached_addon_missing( """Test supervisor issue for detached addon due to missing repository.""" mock_resolution_info(supervisor_client) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_supervisor_ws_client() @@ -958,7 +958,7 @@ async def test_supervisor_issues_ntp_sync_failed( """Test supervisor issue for NTP sync failed.""" mock_resolution_info(supervisor_client) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_supervisor_ws_client() @@ -1013,7 +1013,7 @@ async def test_supervisor_issues_disk_lifetime( """Test supervisor issue for disk lifetime nearly exceeded.""" mock_resolution_info(supervisor_client) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_supervisor_ws_client() @@ -1060,7 +1060,7 @@ async def test_supervisor_issues_free_space( """Test supervisor issue for too little free space remaining.""" mock_resolution_info(supervisor_client) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_supervisor_ws_client() @@ -1114,7 +1114,7 @@ async def test_supervisor_issues_addon_pwned( """Test supervisor issue for pwned secret in an addon.""" mock_resolution_info(supervisor_client) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_supervisor_ws_client() @@ -1170,7 +1170,7 @@ async def test_supervisor_issues_unload_disconnects_listener( the listener — preventing listener accumulation on config-entry reload. """ mock_resolution_info(supervisor_client) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result # Get config entry diff --git a/tests/components/hassio/test_jobs.py b/tests/components/hassio/test_jobs.py index 70a302b44d69..d880dc838bce 100644 --- a/tests/components/hassio/test_jobs.py +++ b/tests/components/hassio/test_jobs.py @@ -9,7 +9,7 @@ from uuid import uuid4 from aiohasupervisor.models import Job, JobsInfo import pytest -from homeassistant.components.hassio.const import MAIN_COORDINATOR +from homeassistant.components.hassio.const import DOMAIN, MAIN_COORDINATOR from homeassistant.components.hassio.coordinator import HassioMainDataUpdateCoordinator from homeassistant.components.hassio.jobs import JobSubscription from homeassistant.core import HomeAssistant, callback @@ -61,7 +61,7 @@ async def test_job_manager_setup(hass: HomeAssistant, jobs_info: AsyncMock) -> N ], ) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result jobs_info.assert_called_once() @@ -76,7 +76,7 @@ async def test_disconnect_on_config_entry_reload( hass: HomeAssistant, jobs_info: AsyncMock ) -> None: """Test dispatcher subscription disconnects on config entry reload.""" - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result jobs_info.assert_called_once() @@ -94,7 +94,7 @@ async def test_job_manager_ws_updates( hass_supervisor_ws_client: WebSocketGenerator, ) -> None: """Test job updates sync from Supervisor WS messages.""" - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result jobs_info.assert_called_once() @@ -302,7 +302,7 @@ async def test_job_manager_reload_on_supervisor_restart( ], ) - result = await async_setup_component(hass, "hassio", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result jobs_info.assert_called_once() diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 5ee57851b8b5..5eba0f3355c2 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -16,6 +16,7 @@ from aiohasupervisor.models import ( ) import pytest +from homeassistant.components.hassio import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component @@ -64,7 +65,7 @@ async def test_supervisor_issue_repair_flow( }, ) - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) repair_issue = issue_registry.async_get_issue( domain="hassio", issue_id=issue_uuid.hex @@ -150,7 +151,7 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions( }, ) - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) repair_issue = issue_registry.async_get_issue( domain="hassio", issue_id=issue_uuid.hex @@ -249,7 +250,7 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions_and_confir }, ) - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) repair_issue = issue_registry.async_get_issue( domain="hassio", issue_id=issue_uuid.hex @@ -357,7 +358,7 @@ async def test_supervisor_issue_repair_flow_skip_confirmation( }, ) - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) repair_issue = issue_registry.async_get_issue( domain="hassio", issue_id=issue_uuid.hex @@ -436,7 +437,7 @@ async def test_supervisor_issue_ntp_sync_failed_repair_flow( }, ) - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) repair_issue = issue_registry.async_get_issue( domain="hassio", issue_id=issue_uuid.hex @@ -516,7 +517,7 @@ async def test_supervisor_issue_ntp_sync_failed_repair_flow_error( suggestion_result=SupervisorError("boom"), ) - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) repair_issue = issue_registry.async_get_issue( domain="hassio", issue_id=issue_uuid.hex @@ -590,7 +591,7 @@ async def test_mount_failed_repair_flow_error( suggestion_result=SupervisorError("boom"), ) - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) repair_issue = issue_registry.async_get_issue( domain="hassio", issue_id=issue_uuid.hex @@ -666,7 +667,7 @@ async def test_mount_failed_repair_flow( }, ) - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) repair_issue = issue_registry.async_get_issue( domain="hassio", issue_id=issue_uuid.hex @@ -792,7 +793,7 @@ async def test_supervisor_issue_docker_config_repair_flow( }, ) - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) repair_issue = issue_registry.async_get_issue( domain="hassio", issue_id=issue1_uuid.hex @@ -878,7 +879,7 @@ async def test_supervisor_issue_repair_flow_multiple_data_disks( }, ) - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) repair_issue = issue_registry.async_get_issue( domain="hassio", issue_id=issue_uuid.hex @@ -989,7 +990,7 @@ async def test_supervisor_issue_detached_addon_removed( }, ) - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) repair_issue = issue_registry.async_get_issue( domain="hassio", issue_id=issue_uuid.hex @@ -1083,7 +1084,7 @@ async def test_supervisor_issue_addon_boot_fail( }, ) - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) repair_issue = issue_registry.async_get_issue( domain="hassio", issue_id=issue_uuid.hex @@ -1183,7 +1184,7 @@ async def test_supervisor_issue_deprecated_addon( }, ) - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) repair_issue = issue_registry.async_get_issue( domain="hassio", issue_id=issue_uuid.hex @@ -1272,7 +1273,7 @@ async def test_supervisor_issue_deprecated_arch_addon( }, ) - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) repair_issue = issue_registry.async_get_issue( domain="hassio", issue_id=issue_uuid.hex diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index e2b885086627..b53b3a40995c 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -119,7 +119,7 @@ async def test_sensor( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -170,7 +170,7 @@ async def test_stats_addon_sensor( assert await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) await hass.async_block_till_done() diff --git a/tests/components/hassio/test_switch.py b/tests/components/hassio/test_switch.py index 4e750d982211..a194e46f91c7 100644 --- a/tests/components/hassio/test_switch.py +++ b/tests/components/hassio/test_switch.py @@ -33,7 +33,7 @@ async def setup_integration( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 23c9b55a4bd6..c14b2d32bead 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -134,7 +134,7 @@ async def test_update_entities( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -156,7 +156,7 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -185,7 +185,7 @@ async def test_update_addon_progress( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -288,7 +288,7 @@ async def test_addon_update_progress_startup( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -385,7 +385,7 @@ async def test_update_addon_with_backup( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -479,7 +479,7 @@ async def test_update_addon_with_backup_removes_old_backups( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -531,7 +531,7 @@ async def test_update_os(hass: HomeAssistant, supervisor_client: AsyncMock) -> N with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -627,7 +627,7 @@ async def test_update_os_with_backup( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -665,7 +665,7 @@ async def test_update_core(hass: HomeAssistant, supervisor_client: AsyncMock) -> with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -697,7 +697,7 @@ async def test_update_core_progress( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -847,7 +847,7 @@ async def test_core_update_progress_startup( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -943,7 +943,7 @@ async def test_update_core_with_backup( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -982,7 +982,7 @@ async def test_update_core_sets_progress_immediately( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -1026,7 +1026,7 @@ async def test_update_core_resets_progress_on_error( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -1065,7 +1065,7 @@ async def test_update_addon_sets_progress_immediately( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -1112,7 +1112,7 @@ async def test_update_addon_resets_progress_on_error( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -1239,7 +1239,7 @@ async def test_update_addon_stays_in_progress_until_refresh( with patch.dict(os.environ, MOCK_ENVIRON): assert await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) await hass.async_block_till_done() @@ -1314,7 +1314,7 @@ async def test_update_addon_completes_on_any_version_change( with patch.dict(os.environ, MOCK_ENVIRON): assert await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) await hass.async_block_till_done() @@ -1345,7 +1345,7 @@ async def test_update_supervisor( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -1379,7 +1379,7 @@ async def test_update_supervisor_progress( with patch.dict(os.environ, MOCK_ENVIRON): assert await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) await hass.async_block_till_done() @@ -1481,7 +1481,7 @@ async def test_update_supervisor_stays_in_progress_until_restart( with patch.dict(os.environ, MOCK_ENVIRON): assert await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) await hass.async_block_till_done() @@ -1547,7 +1547,7 @@ async def test_update_supervisor_completes_on_any_version_change( with patch.dict(os.environ, MOCK_ENVIRON): assert await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) await hass.async_block_till_done() @@ -1601,7 +1601,7 @@ async def test_update_addon_with_error( with patch.dict(os.environ, MOCK_ENVIRON): assert await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) await hass.async_block_till_done() @@ -1637,7 +1637,7 @@ async def test_update_addon_with_backup_and_error( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -1674,7 +1674,7 @@ async def test_update_os_with_error( with patch.dict(os.environ, MOCK_ENVIRON): assert await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) await hass.async_block_till_done() @@ -1702,7 +1702,7 @@ async def test_update_os_with_backup_and_error( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -1738,7 +1738,7 @@ async def test_update_supervisor_with_error( with patch.dict(os.environ, MOCK_ENVIRON): assert await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) await hass.async_block_till_done() @@ -1765,7 +1765,7 @@ async def test_update_core_with_error( with patch.dict(os.environ, MOCK_ENVIRON): assert await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) await hass.async_block_till_done() @@ -1793,7 +1793,7 @@ async def test_update_core_with_backup_and_error( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -1830,7 +1830,7 @@ async def test_release_notes_between_versions( ): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -1866,7 +1866,7 @@ async def test_release_notes_full( ): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -1912,7 +1912,7 @@ async def test_not_release_notes( ): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -1942,7 +1942,7 @@ async def test_no_os_entity( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -1966,7 +1966,7 @@ async def test_setting_up_core_update_when_addon_fails( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) await hass.async_block_till_done() diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index b77360fc81bd..2749484cbcac 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -98,7 +98,7 @@ async def test_ws_subscription( hass: HomeAssistant, hass_supervisor_ws_client: WebSocketGenerator ) -> None: """Test websocket subscription.""" - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) client = await hass_supervisor_ws_client() await client.send_json({WS_ID: 5, WS_TYPE: WS_TYPE_SUBSCRIBE}) response = await client.receive_json() @@ -137,7 +137,7 @@ async def test_admin_non_supervisor_publish_supervisor_event_failure( ) -> None: """Test non admin user cannot publish supervisor event.""" hass_admin_user.groups = [] - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) client = await hass_ws_client(hass) await client.send_json( @@ -159,7 +159,7 @@ async def test_websocket_supervisor_api( aioclient_mock: AiohttpClientMocker, ) -> None: """Test Supervisor websocket api.""" - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) websocket_client = await hass_ws_client(hass) aioclient_mock.post( "http://127.0.0.1/backups/new/partial", @@ -203,7 +203,7 @@ async def test_websocket_supervisor_api_with_params( aioclient_mock: AiohttpClientMocker, ) -> None: """Test Supervisor websocket api with query params.""" - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) websocket_client = await hass_ws_client(hass) aioclient_mock.get( "http://127.0.0.1/backups/backup_id/info", @@ -234,7 +234,7 @@ async def test_websocket_supervisor_api_error( aioclient_mock: AiohttpClientMocker, ) -> None: """Test Supervisor websocket api error.""" - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) websocket_client = await hass_ws_client(hass) aioclient_mock.get( "http://127.0.0.1/ping", @@ -263,7 +263,7 @@ async def test_websocket_supervisor_api_error_without_msg( aioclient_mock: AiohttpClientMocker, ) -> None: """Test Supervisor websocket api error.""" - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) websocket_client = await hass_ws_client(hass) aioclient_mock.get( "http://127.0.0.1/ping", @@ -294,7 +294,7 @@ async def test_websocket_non_admin_user( ) -> None: """Test Supervisor websocket api error.""" hass_admin_user.groups = [] - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) websocket_client = await hass_ws_client(hass) aioclient_mock.get( "http://127.0.0.1/addons/test_addon/info", @@ -388,7 +388,7 @@ async def test_update_addon( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -491,7 +491,7 @@ async def test_update_addon_with_backup( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -634,7 +634,7 @@ async def test_update_addon_with_backup_removes_old_backups( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -697,7 +697,7 @@ async def test_update_core( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -792,7 +792,7 @@ async def test_update_core_with_backup( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -831,7 +831,7 @@ async def test_update_addon_with_error( with patch.dict(os.environ, MOCK_ENVIRON): assert await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) await hass.async_block_till_done() @@ -871,7 +871,7 @@ async def test_update_addon_with_backup_and_error( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -910,7 +910,7 @@ async def test_update_core_with_error( with patch.dict(os.environ, MOCK_ENVIRON): assert await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) await hass.async_block_till_done() @@ -938,7 +938,7 @@ async def test_update_core_with_backup_and_error( with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( hass, - "hassio", + DOMAIN, {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result @@ -969,7 +969,7 @@ async def test_read_update_config( snapshot: SnapshotAssertion, ) -> None: """Test read and update config.""" - assert await async_setup_component(hass, "hassio", {}) + assert await async_setup_component(hass, DOMAIN, {}) websocket_client = await hass_ws_client(hass) await websocket_client.send_json_auto_id({"type": "hassio/update/config/info"}) diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 7e0321385123..f444eca4231c 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -9,6 +9,7 @@ from freezegun import freeze_time import pytest from homeassistant.components import history +from homeassistant.components.history import DOMAIN from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.recorder.models import process_timestamp from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE @@ -382,7 +383,7 @@ async def test_fetch_period_api( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the fetch period view for history.""" - await async_setup_component(hass, "history", {}) + await async_setup_component(hass, DOMAIN, {}) client = await hass_client() response = await client.get( f"/api/history/period/{dt_util.utcnow().isoformat()}?filter_entity_id=sensor.power" @@ -398,7 +399,7 @@ async def test_fetch_period_api_with_use_include_order( ) -> None: """Test the fetch period view for history with include order.""" await async_setup_component( - hass, "history", {history.DOMAIN: {history.CONF_ORDER: True}} + hass, DOMAIN, {history.DOMAIN: {history.CONF_ORDER: True}} ) client = await hass_client() response = await client.get( @@ -415,7 +416,7 @@ async def test_fetch_period_api_with_minimal_response( ) -> None: """Test the fetch period view for history with minimal_response.""" now = dt_util.utcnow() - await async_setup_component(hass, "history", {}) + await async_setup_component(hass, DOMAIN, {}) hass.states.async_set("sensor.power", 0, {"attr": "any"}) await async_wait_recording_done(hass) @@ -457,7 +458,7 @@ async def test_fetch_period_api_with_no_timestamp( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the fetch period view for history with no timestamp.""" - await async_setup_component(hass, "history", {}) + await async_setup_component(hass, DOMAIN, {}) client = await hass_client() response = await client.get("/api/history/period?filter_entity_id=sensor.power") assert response.status == HTTPStatus.OK @@ -472,7 +473,7 @@ async def test_fetch_period_api_with_include_order( """Test the fetch period view for history.""" await async_setup_component( hass, - "history", + DOMAIN, { "history": { "use_include_order": True, @@ -498,7 +499,7 @@ async def test_entity_ids_limit_via_api( """Test limiting history to entity_ids.""" await async_setup_component( hass, - "history", + DOMAIN, {"history": {}}, ) hass.states.async_set("light.kitchen", "on") @@ -525,7 +526,7 @@ async def test_entity_ids_limit_via_api_with_skip_initial_state( """Test limiting history to entity_ids with skip_initial_state.""" await async_setup_component( hass, - "history", + DOMAIN, {"history": {}}, ) hass.states.async_set("light.kitchen", "on") @@ -560,7 +561,7 @@ async def test_fetch_period_api_before_history_started( """Test the fetch period view for history for the far past.""" await async_setup_component( hass, - "history", + DOMAIN, {}, ) await async_wait_recording_done(hass) @@ -582,7 +583,7 @@ async def test_fetch_period_api_far_future( """Test the fetch period view for history for the far future.""" await async_setup_component( hass, - "history", + DOMAIN, {}, ) await async_wait_recording_done(hass) @@ -604,7 +605,7 @@ async def test_fetch_period_api_with_invalid_datetime( """Test the fetch period view for history with an invalid date time.""" await async_setup_component( hass, - "history", + DOMAIN, {}, ) await async_wait_recording_done(hass) @@ -624,7 +625,7 @@ async def test_fetch_period_api_invalid_end_time( """Test the fetch period view for history with an invalid end time.""" await async_setup_component( hass, - "history", + DOMAIN, {}, ) await async_wait_recording_done(hass) @@ -647,7 +648,7 @@ async def test_entity_ids_limit_via_api_with_end_time( """Test limiting history to entity_ids with end_time.""" await async_setup_component( hass, - "history", + DOMAIN, {"history": {}}, ) start = dt_util.utcnow() @@ -692,7 +693,7 @@ async def test_fetch_period_api_with_no_entity_ids( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the fetch period view for history with minimal_response.""" - await async_setup_component(hass, "history", {}) + await async_setup_component(hass, DOMAIN, {}) await async_wait_recording_done(hass) yesterday = dt_util.utcnow() - timedelta(days=1) @@ -753,7 +754,7 @@ async def test_history_with_invalid_entity_ids( """Test sending valid and invalid entity_ids to the API.""" await async_setup_component( hass, - "history", + DOMAIN, {"history": {}}, ) hass.states.async_set("light.kitchen", "on") @@ -785,7 +786,7 @@ async def test_fetch_period_api_filters_unauthorized_entities( hass_read_only_user.mock_policy( {"entities": {"entity_ids": {"light.kitchen": True}}} ) - await async_setup_component(hass, "history", {}) + await async_setup_component(hass, DOMAIN, {}) hass.states.async_set("light.kitchen", "on") hass.states.async_set("light.cow", "on") diff --git a/tests/components/history/test_websocket_api.py b/tests/components/history/test_websocket_api.py index b8f3f78cefc9..f0c84a83e3e7 100644 --- a/tests/components/history/test_websocket_api.py +++ b/tests/components/history/test_websocket_api.py @@ -11,7 +11,7 @@ from freezegun import freeze_time import pytest from homeassistant.components import history -from homeassistant.components.history import websocket_api +from homeassistant.components.history import DOMAIN, websocket_api from homeassistant.const import ( EVENT_HOMEASSISTANT_FINAL_WRITE, EVENT_STATE_CHANGED, @@ -77,7 +77,7 @@ async def test_history_during_period( """Test history_during_period.""" now = dt_util.utcnow() - await async_setup_component(hass, "history", {}) + await async_setup_component(hass, DOMAIN, {}) await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) @@ -210,7 +210,7 @@ async def test_history_during_period_impossible_conditions( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test history_during_period returns when condition cannot be true.""" - await async_setup_component(hass, "history", {}) + await async_setup_component(hass, DOMAIN, {}) await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) @@ -278,7 +278,7 @@ async def test_history_during_period_significant_domain( await hass.config.async_set_time_zone(time_zone) now = dt_util.utcnow() - await async_setup_component(hass, "history", {}) + await async_setup_component(hass, DOMAIN, {}) await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) hass.states.async_set("climate.test", "on", attributes={"temperature": "1"}) @@ -443,7 +443,7 @@ async def test_history_during_period_bad_start_time( """Test history_during_period bad state time.""" await async_setup_component( hass, - "history", + DOMAIN, {"history": {}}, ) @@ -470,7 +470,7 @@ async def test_history_during_period_bad_end_time( await async_setup_component( hass, - "history", + DOMAIN, {"history": {}}, ) @@ -497,7 +497,7 @@ async def test_history_stream_historical_only( now = dt_util.utcnow() await async_setup_component( hass, - "history", + DOMAIN, {}, ) await async_setup_component(hass, "sensor", {}) @@ -586,7 +586,7 @@ async def test_history_stream_significant_domain_historical_only( """Test the stream with climate domain with historical states only.""" now = dt_util.utcnow() - await async_setup_component(hass, "history", {}) + await async_setup_component(hass, DOMAIN, {}) await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) hass.states.async_set("climate.test", "on", attributes={"temperature": "1"}) @@ -788,7 +788,7 @@ async def test_history_stream_bad_start_time( """Test history stream bad state time.""" await async_setup_component( hass, - "history", + DOMAIN, {"history": {}}, ) @@ -816,7 +816,7 @@ async def test_history_stream_end_time_before_start_time( await async_setup_component( hass, - "history", + DOMAIN, {"history": {}}, ) @@ -844,7 +844,7 @@ async def test_history_stream_bad_end_time( await async_setup_component( hass, - "history", + DOMAIN, {"history": {}}, ) @@ -871,7 +871,7 @@ async def test_history_stream_live_no_attributes_minimal_response( now = dt_util.utcnow() await async_setup_component( hass, - "history", + DOMAIN, {}, ) await async_setup_component(hass, "sensor", {}) @@ -965,7 +965,7 @@ async def test_history_stream_live( now = dt_util.utcnow() await async_setup_component( hass, - "history", + DOMAIN, {}, ) await async_setup_component(hass, "sensor", {}) @@ -1079,7 +1079,7 @@ async def test_history_stream_live_minimal_response( now = dt_util.utcnow() await async_setup_component( hass, - "history", + DOMAIN, {}, ) await async_setup_component(hass, "sensor", {}) @@ -1185,7 +1185,7 @@ async def test_history_stream_live_no_attributes( now = dt_util.utcnow() await async_setup_component( hass, - "history", + DOMAIN, {}, ) await async_setup_component(hass, "sensor", {}) @@ -1291,7 +1291,7 @@ async def test_history_stream_live_no_attributes_minimal_response_specific_entit wanted_entities = ["sensor.two", "sensor.four", "sensor.one"] await async_setup_component( hass, - "history", + DOMAIN, {history.DOMAIN: {}}, ) await async_setup_component(hass, "sensor", {}) @@ -1386,7 +1386,7 @@ async def test_history_stream_live_with_future_end_time( wanted_entities = ["sensor.two", "sensor.four", "sensor.one"] await async_setup_component( hass, - "history", + DOMAIN, {history.DOMAIN: {}}, ) await async_setup_component(hass, "sensor", {}) @@ -1496,7 +1496,7 @@ async def test_history_stream_before_history_starts( """Test history stream before we have history.""" await async_setup_component( hass, - "history", + DOMAIN, {}, ) await async_setup_component(hass, "sensor", {}) @@ -1548,7 +1548,7 @@ async def test_history_stream_for_entity_with_no_possible_changes( """ await async_setup_component( hass, - "history", + DOMAIN, {}, ) await async_setup_component(hass, "sensor", {}) @@ -1624,7 +1624,7 @@ async def test_overflow_queue( ): await async_setup_component( hass, - "history", + DOMAIN, {history.DOMAIN: {}}, ) await async_setup_component(hass, "sensor", {}) @@ -1711,7 +1711,7 @@ async def test_history_during_period_for_invalid_entity_ids( """Test history_during_period for valid and invalid entity ids.""" now = dt_util.utcnow() - await async_setup_component(hass, "history", {}) + await async_setup_component(hass, DOMAIN, {}) await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) hass.states.async_set("sensor.one", "on", attributes={"any": "attr"}) @@ -1873,7 +1873,7 @@ async def test_history_stream_for_invalid_entity_ids( now = dt_util.utcnow() await async_setup_component( hass, - "history", + DOMAIN, {history.DOMAIN: {}}, ) @@ -2050,7 +2050,7 @@ async def test_history_stream_historical_only_with_start_time_state_past( """Test history stream.""" await async_setup_component( hass, - "history", + DOMAIN, {}, ) await async_setup_component(hass, "sensor", {}) @@ -2161,7 +2161,7 @@ async def test_history_stream_live_chained_events( ) -> None: """Test history stream with history with a chained event.""" now = dt_util.utcnow() - await async_setup_component(hass, "history", {}) + await async_setup_component(hass, DOMAIN, {}) hass.states.async_set("binary_sensor.is_light", STATE_OFF) await async_wait_recording_done(hass) @@ -2251,7 +2251,7 @@ async def test_history_during_period_filters_unauthorized_entities( ) now = dt_util.utcnow() - await async_setup_component(hass, "history", {}) + await async_setup_component(hass, DOMAIN, {}) await async_recorder_block_till_done(hass) hass.states.async_set("sensor.allowed", "on") hass.states.async_set("sensor.forbidden", "on") @@ -2303,7 +2303,7 @@ async def test_history_stream_filters_unauthorized_entities( ) now = dt_util.utcnow() - await async_setup_component(hass, "history", {}) + await async_setup_component(hass, DOMAIN, {}) await async_recorder_block_till_done(hass) hass.states.async_set("sensor.allowed", "on") hass.states.async_set("sensor.forbidden", "on") diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py index 88732cb6f20f..c68138c9dccb 100644 --- a/tests/components/homeassistant/test_exposed_entities.py +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -3,6 +3,7 @@ import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.homeassistant import DOMAIN from homeassistant.components.homeassistant.exposed_entities import ( DATA_EXPOSED_ENTITIES, ExposedEntities, @@ -101,7 +102,7 @@ def entities_no_unique_id(hass: HomeAssistant) -> dict[str, str]: async def test_load_preferences(hass: HomeAssistant) -> None: """Make sure that we can load/save data correctly.""" - assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {}) exposed_entities = hass.data[DATA_EXPOSED_ENTITIES] assert exposed_entities._assistants == {} @@ -134,7 +135,7 @@ async def test_expose_entity( ) -> None: """Test expose entity.""" ws_client = await hass_ws_client(hass) - assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() entry1 = entity_registry.async_get_or_create("test", "test", "unique1") @@ -194,7 +195,7 @@ async def test_expose_entity_unknown( ) -> None: """Test behavior when exposing an unknown entity.""" ws_client = await hass_ws_client(hass) - assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() exposed_entities = hass.data[DATA_EXPOSED_ENTITIES] @@ -257,7 +258,7 @@ async def test_expose_new_entities( ) -> None: """Test expose entity.""" ws_client = await hass_ws_client(hass) - assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() entry1 = entity_registry.async_get_or_create("climate", "test", "unique1") @@ -313,7 +314,7 @@ async def test_listen_updates( def listener(): calls.append(None) - assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() async_listen_entity_updates(hass, "cloud.alexa", listener) @@ -343,7 +344,7 @@ async def test_get_assistant_settings( snapshot: SnapshotAssertion, ) -> None: """Test get assistant settings.""" - assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() entry = entity_registry.async_get_or_create("climate", "test", "unique1") @@ -370,7 +371,7 @@ async def test_should_expose( ) -> None: """Test expose entity.""" ws_client = await hass_ws_client(hass) - assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() # Expose new entities to Alexa @@ -433,7 +434,7 @@ async def test_should_expose_hidden_categorized( ) -> None: """Test expose entity.""" ws_client = await hass_ws_client(hass) - assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() # Expose new entities to Alexa @@ -466,7 +467,7 @@ async def test_list_exposed_entities( ) -> None: """Test list exposed entities.""" ws_client = await hass_ws_client(hass) - assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() entry1 = entity_registry.async_get_or_create("test", "test", "unique1") @@ -535,7 +536,7 @@ async def test_listeners( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Make sure we call entity listeners.""" - assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {}) exposed_entities = hass.data[DATA_EXPOSED_ENTITIES] diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index a55ca38d5a23..d166a759a96f 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -201,7 +201,7 @@ async def test_turn_on_skips_domains_without_service( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test if turn_on is blocking domain with no service.""" - await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, DOMAIN, {}) async_mock_service(hass, "light", SERVICE_TURN_ON) hass.states.async_set("light.Bowl", STATE_ON) hass.states.async_set("light.Ceiling", STATE_OFF) @@ -241,7 +241,7 @@ async def test_turn_on_skips_domains_without_service( async def test_entity_update(hass: HomeAssistant) -> None: """Test being able to call entity update.""" - await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, DOMAIN, {}) with patch( "homeassistant.components.homeassistant.async_update_entity", @@ -260,7 +260,7 @@ async def test_entity_update(hass: HomeAssistant) -> None: async def test_setting_location(hass: HomeAssistant) -> None: """Test setting the location.""" - await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, DOMAIN, {}) events = async_capture_events(hass, EVENT_CORE_CONFIG_UPDATE) # Just to make sure that we are updating values. assert hass.config.latitude != 30 @@ -303,7 +303,7 @@ async def test_require_admin( hass: HomeAssistant, hass_read_only_user: MockUser ) -> None: """Test services requiring admin.""" - await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, DOMAIN, {}) for service in ( SERVICE_HOMEASSISTANT_RESTART, @@ -334,7 +334,7 @@ async def test_turn_on_off_toggle_schema( hass: HomeAssistant, hass_read_only_user: MockUser ) -> None: """Test the schemas for the turn on/off/toggle services.""" - await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, DOMAIN, {}) for service in SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE: for invalid in None, "nothing", ENTITY_MATCH_ALL, ENTITY_MATCH_NONE: @@ -352,7 +352,7 @@ async def test_not_allowing_recursion( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we do not allow recursion.""" - await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, DOMAIN, {}) for service in SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE: await hass.services.async_call( @@ -371,7 +371,7 @@ async def test_reload_config_entry_by_entity_id( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test being able to reload a config entry by entity_id.""" - await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, DOMAIN, {}) entry1 = MockConfigEntry(domain="mockdomain") entry1.add_to_hass(hass) entry2 = MockConfigEntry(domain="mockdomain") @@ -410,7 +410,7 @@ async def test_reload_config_entry_by_entity_id( async def test_reload_config_entry_by_entry_id(hass: HomeAssistant) -> None: """Test being able to reload a config entry by config entry id.""" - await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, DOMAIN, {}) with patch( "homeassistant.config_entries.ConfigEntries.async_reload", @@ -434,7 +434,7 @@ async def test_raises_when_db_upgrade_in_progress( hass: HomeAssistant, service, caplog: pytest.LogCaptureFixture ) -> None: """Test an exception is raised when the database migration is in progress.""" - await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, DOMAIN, {}) with ( pytest.raises(HomeAssistantError), @@ -476,7 +476,7 @@ async def test_raises_when_config_is_invalid( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test an exception is raised when the configuration is invalid.""" - await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, DOMAIN, {}) with ( pytest.raises(HomeAssistantError), @@ -526,7 +526,7 @@ async def test_restart_homeassistant( hass: HomeAssistant, service_data: dict, safe_mode_enabled: bool ) -> None: """Test we can restart when there is no configuration error.""" - await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, DOMAIN, {}) with ( patch( "homeassistant.config.async_check_ha_config_file", return_value=None @@ -550,7 +550,7 @@ async def test_restart_homeassistant( async def test_stop_homeassistant(hass: HomeAssistant) -> None: """Test we can stop when there is a configuration error.""" - await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, DOMAIN, {}) with ( patch( "homeassistant.config.async_check_ha_config_file", return_value=None @@ -571,7 +571,7 @@ async def test_stop_homeassistant(hass: HomeAssistant) -> None: async def test_save_persistent_states(hass: HomeAssistant) -> None: """Test we can call save_persistent_states.""" - await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, DOMAIN, {}) with patch( "homeassistant.helpers.restore_state.RestoreStateData.async_save_persistent_states", return_value=None, @@ -586,7 +586,7 @@ async def test_save_persistent_states(hass: HomeAssistant) -> None: async def test_reload_custom_templates(hass: HomeAssistant) -> None: """Test we can call reload_custom_templates.""" - await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, DOMAIN, {}) with patch( "homeassistant.components.homeassistant.async_load_custom_templates", return_value=None, @@ -603,7 +603,7 @@ async def test_reload_all( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test reload_all service.""" - await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, DOMAIN, {}) test1 = async_mock_service(hass, "test1", "reload") test2 = async_mock_service(hass, "test2", "reload") no_reload = async_mock_service(hass, "test3", "not_reload") diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index 5b5e105bb9b6..be6f7f5f96df 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -531,7 +531,7 @@ async def test_bad_config_entry_fixing(hass: HomeAssistant) -> None: ) ], ): - await async_setup_component(hass, "homeassistant_sky_connect", {}) + await async_setup_component(hass, DOMAIN, {}) assert hass.config_entries.async_get_entry(new_entry.entry_id) is not None assert hass.config_entries.async_get_entry(old_entry.entry_id) is not None diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index dfa2b4493990..a1d4b0dd0517 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -393,10 +393,10 @@ async def test_options_flow_devices( with patch("homeassistant.components.homekit.HomeKit") as mock_homekit: mock_homekit.return_value = homekit = Mock() type(homekit).async_start = AsyncMock() - assert await async_setup_component(hass, "homekit", {"homekit": {}}) + assert await async_setup_component(hass, DOMAIN, {"homekit": {}}) assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "demo", {"demo": {}}) - assert await async_setup_component(hass, "homekit", {"homekit": {}}) + assert await async_setup_component(hass, DOMAIN, {"homekit": {}}) hass.states.async_set("climate.old", "off") await hass.async_block_till_done() diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 60a5ff26fa09..c1b301657746 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -1721,7 +1721,7 @@ async def test_yaml_updates_update_config_entry_for_name(hass: HomeAssistant) -> mock_homekit.return_value = homekit = Mock() type(homekit).async_start = AsyncMock() assert await async_setup_component( - hass, "homekit", {"homekit": {CONF_NAME: BRIDGE_NAME, CONF_PORT: 12345}} + hass, DOMAIN, {"homekit": {CONF_NAME: BRIDGE_NAME, CONF_PORT: 12345}} ) await hass.async_block_till_done() @@ -1770,7 +1770,7 @@ async def test_yaml_can_link_with_default_name(hass: HomeAssistant) -> None: type(homekit).async_start = AsyncMock() assert await async_setup_component( hass, - "homekit", + DOMAIN, {"homekit": {"entity_config": {"camera.back_camera": {"stream_count": 3}}}}, ) await hass.async_block_till_done() @@ -1816,7 +1816,7 @@ async def test_yaml_can_link_with_port(hass: HomeAssistant) -> None: type(homekit).async_start = AsyncMock() assert await async_setup_component( hass, - "homekit", + DOMAIN, { "homekit": { "port": 12345, @@ -2304,7 +2304,7 @@ async def test_reload(mock_port_available: MagicMock, hass: HomeAssistant) -> No mock_homekit.return_value = homekit = Mock() type(homekit).async_start = AsyncMock() assert await async_setup_component( - hass, "homekit", {"homekit": {CONF_NAME: "reloadable", CONF_PORT: 12345}} + hass, DOMAIN, {"homekit": {CONF_NAME: "reloadable", CONF_PORT: 12345}} ) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_init.py b/tests/components/homekit/test_init.py index 7ab6048fb100..bf715f05b5e1 100644 --- a/tests/components/homekit/test_init.py +++ b/tests/components/homekit/test_init.py @@ -32,7 +32,7 @@ async def test_humanify_homekit_changed_event(hass: HomeAssistant, hk_driver) -> with patch("homeassistant.components.homekit.HomeKit") as mock_homekit: mock_homekit.return_value = homekit = Mock() type(homekit).async_start = AsyncMock() - assert await async_setup_component(hass, "homekit", {"homekit": {}}) + assert await async_setup_component(hass, DOMAIN, {"homekit": {}}) assert await async_setup_component(hass, "logbook", {}) await hass.async_block_till_done() diff --git a/tests/components/homematic/test_notify.py b/tests/components/homematic/test_notify.py index f3bfc5cb44cb..0c9cb26c6afd 100644 --- a/tests/components/homematic/test_notify.py +++ b/tests/components/homematic/test_notify.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch +from homeassistant.components.homematic import DOMAIN from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -17,7 +18,7 @@ async def test_setup_full(hass: HomeAssistant) -> None: ): await async_setup_component( hass, - "homematic", + DOMAIN, {"homematic": {"hosts": {"ccu2": {"host": "127.0.0.1"}}}}, ) with assert_setup_component(1, domain="notify") as handle_config: @@ -47,7 +48,7 @@ async def test_setup_without_optional(hass: HomeAssistant) -> None: ): await async_setup_component( hass, - "homematic", + DOMAIN, {"homematic": {"hosts": {"ccu2": {"host": "127.0.0.1"}}}}, ) with assert_setup_component(1, domain="notify") as handle_config: diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 6997b78d8e45..a2f5e281e66f 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -18,6 +18,7 @@ from homeassistant.auth.models import User from homeassistant.auth.providers import trusted_networks from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import websocket_api +from homeassistant.components.http import DOMAIN from homeassistant.components.http.auth import ( CONTENT_USER_NAME, DATA_SIGN_SECRET, @@ -111,7 +112,7 @@ def trusted_networks_auth( async def test_auth_middleware_loaded_by_default(hass: HomeAssistant) -> None: """Test accessing to server from banned IP when feature is off.""" with patch("homeassistant.components.http.async_setup_auth") as mock_setup: - await async_setup_component(hass, "http", {"http": {}}) + await async_setup_component(hass, DOMAIN, {"http": {}}) assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index 3ea91ad99141..149067eeb858 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -12,6 +12,7 @@ from aiohttp.web_middlewares import middleware import pytest from homeassistant.components import http +from homeassistant.components.http import DOMAIN from homeassistant.components.http.ban import ( IP_BANS_FILE, KEY_BAN_MANAGER, @@ -311,7 +312,7 @@ async def test_ban_middleware_not_loaded_by_config(hass: HomeAssistant) -> None: """Test accessing to server from banned IP when feature is off.""" with patch("homeassistant.components.http.setup_bans") as mock_setup: await async_setup_component( - hass, "http", {"http": {http.CONF_IP_BAN_ENABLED: False}} + hass, DOMAIN, {"http": {http.CONF_IP_BAN_ENABLED: False}} ) assert len(mock_setup.mock_calls) == 0 @@ -320,7 +321,7 @@ async def test_ban_middleware_not_loaded_by_config(hass: HomeAssistant) -> None: async def test_ban_middleware_loaded_by_default(hass: HomeAssistant) -> None: """Test accessing to server from banned IP when feature is off.""" with patch("homeassistant.components.http.setup_bans") as mock_setup: - await async_setup_component(hass, "http", {"http": {}}) + await async_setup_component(hass, DOMAIN, {"http": {}}) assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index bddd66a7e81e..3d3d6ba89e1f 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -16,7 +16,7 @@ from aiohttp.hdrs import ( from aiohttp.test_utils import TestClient import pytest -from homeassistant.components.http import StaticPathConfig +from homeassistant.components.http import DOMAIN, StaticPathConfig from homeassistant.components.http.cors import setup_cors from homeassistant.core import HomeAssistant from homeassistant.helpers.http import KEY_ALLOW_CONFIGURED_CORS, HomeAssistantView @@ -32,7 +32,7 @@ TRUSTED_ORIGIN = "https://home-assistant.io" async def test_cors_middleware_loaded_by_default(hass: HomeAssistant) -> None: """Test accessing to server from banned IP when feature is off.""" with patch("homeassistant.components.http.setup_cors") as mock_setup: - await async_setup_component(hass, "http", {"http": {}}) + await async_setup_component(hass, DOMAIN, {"http": {}}) assert len(mock_setup.mock_calls) == 1 @@ -42,7 +42,7 @@ async def test_cors_middleware_loaded_from_config(hass: HomeAssistant) -> None: with patch("homeassistant.components.http.setup_cors") as mock_setup: await async_setup_component( hass, - "http", + DOMAIN, {"http": {"cors_allowed_origins": ["http://home-assistant.io"]}}, ) @@ -126,7 +126,7 @@ async def test_cors_middleware_with_cors_allowed_view(hass: HomeAssistant) -> No return "test" assert await async_setup_component( - hass, "http", {"http": {"cors_allowed_origins": ["http://home-assistant.io"]}} + hass, DOMAIN, {"http": {"cors_allowed_origins": ["http://home-assistant.io"]}} ) hass.http.register_view(MyView("/api/test", "api:test")) diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 4559c52875d8..5b231c8c3687 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -15,6 +15,7 @@ import pytest from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import cloud, http from homeassistant.components.cloud import CloudNotAvailable +from homeassistant.components.http import DOMAIN from homeassistant.const import HASSIO_USER_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -148,7 +149,7 @@ async def test_proxy_config(hass: HomeAssistant) -> None: assert ( await async_setup_component( hass, - "http", + DOMAIN, { "http": { http.CONF_USE_X_FORWARDED_FOR: True, @@ -164,7 +165,7 @@ async def test_proxy_config_only_use_xff(hass: HomeAssistant) -> None: """Test use_x_forwarded_for must config together with trusted_proxies.""" assert ( await async_setup_component( - hass, "http", {"http": {http.CONF_USE_X_FORWARDED_FOR: True}} + hass, DOMAIN, {"http": {http.CONF_USE_X_FORWARDED_FOR: True}} ) is not True ) @@ -174,7 +175,7 @@ async def test_proxy_config_only_trust_proxies(hass: HomeAssistant) -> None: """Test use_x_forwarded_for must config together with trusted_proxies.""" assert ( await async_setup_component( - hass, "http", {"http": {http.CONF_TRUSTED_PROXIES: ["127.0.0.1"]}} + hass, DOMAIN, {"http": {http.CONF_TRUSTED_PROXIES: ["127.0.0.1"]}} ) is not True ) @@ -197,7 +198,7 @@ async def test_ssl_profile_defaults_modern(hass: HomeAssistant, tmp_path: Path) assert ( await async_setup_component( hass, - "http", + DOMAIN, {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}}, ) is True @@ -227,7 +228,7 @@ async def test_ssl_profile_change_intermediate( assert ( await async_setup_component( hass, - "http", + DOMAIN, { "http": { "ssl_profile": "intermediate", @@ -261,7 +262,7 @@ async def test_ssl_profile_change_modern(hass: HomeAssistant, tmp_path: Path) -> assert ( await async_setup_component( hass, - "http", + DOMAIN, { "http": { "ssl_profile": "modern", @@ -295,7 +296,7 @@ async def test_peer_cert(hass: HomeAssistant, tmp_path: Path) -> None: assert ( await async_setup_component( hass, - "http", + DOMAIN, { "http": { "ssl_peer_certificate": peer_cert_path, @@ -327,7 +328,7 @@ async def test_emergency_ssl_certificate_when_invalid( assert ( await async_setup_component( hass, - "http", + DOMAIN, { "http": {"ssl_certificate": cert_path, "ssl_key": key_path}, }, @@ -357,7 +358,7 @@ async def test_emergency_ssl_certificate_not_used_when_not_recovery_mode( assert ( await async_setup_component( - hass, "http", {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}} + hass, DOMAIN, {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}} ) is False ) @@ -381,7 +382,7 @@ async def test_emergency_ssl_certificate_when_invalid_get_url_fails( assert ( await async_setup_component( hass, - "http", + DOMAIN, { "http": {"ssl_certificate": cert_path, "ssl_key": key_path}, }, @@ -417,7 +418,7 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert( assert ( await async_setup_component( hass, - "http", + DOMAIN, { "http": {"ssl_certificate": cert_path, "ssl_key": key_path}, }, @@ -454,7 +455,7 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert( assert ( await async_setup_component( hass, - "http", + DOMAIN, { "http": { "ssl_certificate": cert_path, @@ -474,7 +475,7 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert( async def test_cors_defaults(hass: HomeAssistant) -> None: """Test the CORS default settings.""" with patch("homeassistant.components.http.setup_cors") as mock_setup: - assert await async_setup_component(hass, "http", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert len(mock_setup.mock_calls) == 1 assert mock_setup.mock_calls[0][1][1] == ["https://cast.home-assistant.io"] @@ -558,7 +559,7 @@ async def test_ssl_issue_if_no_urls_configured( ): assert await async_setup_component( hass, - "http", + DOMAIN, {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}}, ) await hass.async_start() @@ -590,7 +591,7 @@ async def test_ssl_issue_if_using_cloud( ): assert await async_setup_component( hass, - "http", + DOMAIN, {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}}, ) await hass.async_start() @@ -628,7 +629,7 @@ async def test_ssl_issue_if_not_connected_to_cloud( ): assert await async_setup_component( hass, - "http", + DOMAIN, {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}}, ) await hass.async_start() @@ -670,7 +671,7 @@ async def test_ssl_issue_urls_configured( ): assert await async_setup_component( hass, - "http", + DOMAIN, {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}}, ) await hass.async_start() @@ -722,7 +723,7 @@ async def test_server_host( ): assert await async_setup_component( hass, - "http", + DOMAIN, {"http": http_config}, ) await hass.async_start() @@ -766,7 +767,7 @@ async def test_unix_socket_started_with_supervisor( loop, "create_unix_server", return_value=Mock() ) as mock_create_unix, ): - assert await async_setup_component(hass, "http", {"http": {}}) + assert await async_setup_component(hass, DOMAIN, {"http": {}}) await hass.async_start() await hass.async_block_till_done() @@ -784,7 +785,7 @@ async def test_unix_socket_not_started_without_supervisor( patch("asyncio.BaseEventLoop.create_server", return_value=Mock()), ): os.environ.pop("SUPERVISOR_CORE_API_SOCKET", None) - assert await async_setup_component(hass, "http", {"http": {}}) + assert await async_setup_component(hass, DOMAIN, {"http": {}}) await hass.async_start() await hass.async_block_till_done() @@ -804,7 +805,7 @@ async def test_unix_socket_rejected_relative_path( ), patch("asyncio.BaseEventLoop.create_server", return_value=Mock()), ): - assert await async_setup_component(hass, "http", {"http": {}}) + assert await async_setup_component(hass, DOMAIN, {"http": {}}) await hass.async_start() await hass.async_block_till_done() diff --git a/tests/components/http/test_static.py b/tests/components/http/test_static.py index 2ac7c6ded938..ef2d3daa2519 100644 --- a/tests/components/http/test_static.py +++ b/tests/components/http/test_static.py @@ -6,7 +6,7 @@ from pathlib import Path from aiohttp.test_utils import TestClient import pytest -from homeassistant.components.http import StaticPathConfig +from homeassistant.components.http import DOMAIN, StaticPathConfig from homeassistant.components.http.static import CachingStaticResource from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.core import HomeAssistant @@ -19,7 +19,7 @@ from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) async def http(hass: HomeAssistant) -> None: """Ensure http is set up.""" - assert await async_setup_component(hass, "http", {}) + assert await async_setup_component(hass, DOMAIN, {}) hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() diff --git a/tests/components/hue/test_init.py b/tests/components/hue/test_init.py index 6b162a221654..10e6a7fb8d8a 100644 --- a/tests/components/hue/test_init.py +++ b/tests/components/hue/test_init.py @@ -7,6 +7,7 @@ import pytest from homeassistant import config_entries from homeassistant.components import hue +from homeassistant.components.hue import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -162,7 +163,7 @@ async def test_security_vuln_check(hass: HomeAssistant) -> None: ), ), ): - assert await async_setup_component(hass, "hue", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/image_upload/test_init.py b/tests/components/image_upload/test_init.py index d404f1f841e4..1b30f12b7e66 100644 --- a/tests/components/image_upload/test_init.py +++ b/tests/components/image_upload/test_init.py @@ -7,6 +7,7 @@ from unittest.mock import patch from aiohttp import ClientSession, ClientWebSocketResponse from freezegun.api import FrozenDateTimeFactory +from homeassistant.components.image_upload import DOMAIN from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -31,7 +32,7 @@ async def test_upload_image( tempfile.TemporaryDirectory() as tempdir, patch.object(hass.config, "path", return_value=tempdir), ): - assert await async_setup_component(hass, "image_upload", {}) + assert await async_setup_component(hass, DOMAIN, {}) ws_client: ClientWebSocketResponse = await hass_ws_client() client: ClientSession = await hass_client() diff --git a/tests/components/image_upload/test_media_source.py b/tests/components/image_upload/test_media_source.py index cf2be99958f7..b1910d751935 100644 --- a/tests/components/image_upload/test_media_source.py +++ b/tests/components/image_upload/test_media_source.py @@ -8,6 +8,7 @@ from aiohttp import ClientSession import pytest from homeassistant.components import media_source +from homeassistant.components.image_upload import DOMAIN from homeassistant.components.media_player import BrowseError from homeassistant.components.media_source import Unresolvable from homeassistant.core import HomeAssistant @@ -32,7 +33,7 @@ async def __upload_test_image( tempfile.TemporaryDirectory() as tempdir, patch.object(hass.config, "path", return_value=tempdir), ): - assert await async_setup_component(hass, "image_upload", {}) + assert await async_setup_component(hass, DOMAIN, {}) client: ClientSession = await hass_client() file = await hass.async_add_executor_job(TEST_IMAGE.open, "rb") diff --git a/tests/components/input_boolean/test_init.py b/tests/components/input_boolean/test_init.py index 0633846f21d9..84d773f3930e 100644 --- a/tests/components/input_boolean/test_init.py +++ b/tests/components/input_boolean/test_init.py @@ -179,7 +179,7 @@ async def test_input_boolean_context( ) -> None: """Test that input_boolean context works.""" assert await async_setup_component( - hass, "input_boolean", {"input_boolean": {"ac": {CONF_INITIAL: True}}} + hass, DOMAIN, {"input_boolean": {"ac": {CONF_INITIAL: True}}} ) state = hass.states.get("input_boolean.ac") diff --git a/tests/components/input_boolean/test_reproduce_state.py b/tests/components/input_boolean/test_reproduce_state.py index b61f110d5b7a..8bdd58886628 100644 --- a/tests/components/input_boolean/test_reproduce_state.py +++ b/tests/components/input_boolean/test_reproduce_state.py @@ -1,5 +1,6 @@ """Test reproduce state for input boolean.""" +from homeassistant.components.input_boolean import DOMAIN from homeassistant.core import HomeAssistant, State from homeassistant.helpers.state import async_reproduce_state from homeassistant.setup import async_setup_component @@ -9,7 +10,7 @@ async def test_reproducing_states(hass: HomeAssistant) -> None: """Test reproducing input_boolean states.""" assert await async_setup_component( hass, - "input_boolean", + DOMAIN, { "input_boolean": { "initial_on": {"initial": True}, diff --git a/tests/components/input_datetime/test_init.py b/tests/components/input_datetime/test_init.py index 474cda010695..9e3364ebc424 100644 --- a/tests/components/input_datetime/test_init.py +++ b/tests/components/input_datetime/test_init.py @@ -443,7 +443,7 @@ async def test_input_datetime_context( ) -> None: """Test that input_datetime context works.""" assert await async_setup_component( - hass, "input_datetime", {"input_datetime": {"only_date": {"has_date": True}}} + hass, DOMAIN, {"input_datetime": {"only_date": {"has_date": True}}} ) state = hass.states.get("input_datetime.only_date") diff --git a/tests/components/input_number/test_init.py b/tests/components/input_number/test_init.py index 79dab8c2bbf0..6b94a68035b8 100644 --- a/tests/components/input_number/test_init.py +++ b/tests/components/input_number/test_init.py @@ -327,7 +327,7 @@ async def test_input_number_context( ) -> None: """Test that input_number context works.""" assert await async_setup_component( - hass, "input_number", {"input_number": {"b1": {"min": 0, "max": 100}}} + hass, DOMAIN, {"input_number": {"b1": {"min": 0, "max": 100}}} ) state = hass.states.get("input_number.b1") diff --git a/tests/components/input_number/test_reproduce_state.py b/tests/components/input_number/test_reproduce_state.py index 2ff757736f33..0fe50785c8a4 100644 --- a/tests/components/input_number/test_reproduce_state.py +++ b/tests/components/input_number/test_reproduce_state.py @@ -2,6 +2,7 @@ import pytest +from homeassistant.components.input_number import DOMAIN from homeassistant.core import HomeAssistant, State from homeassistant.helpers.state import async_reproduce_state from homeassistant.setup import async_setup_component @@ -17,7 +18,7 @@ async def test_reproducing_states( assert await async_setup_component( hass, - "input_number", + DOMAIN, { "input_number": { "test_number": {"min": "5", "max": "100", "initial": VALID_NUMBER1} diff --git a/tests/components/input_select/test_init.py b/tests/components/input_select/test_init.py index c53e105bd090..c5fc0d409337 100644 --- a/tests/components/input_select/test_init.py +++ b/tests/components/input_select/test_init.py @@ -424,7 +424,7 @@ async def test_input_select_context( """Test that input_select context works.""" assert await async_setup_component( hass, - "input_select", + DOMAIN, { "input_select": { "s1": {"options": ["first option", "middle option", "last option"]} diff --git a/tests/components/input_select/test_reproduce_state.py b/tests/components/input_select/test_reproduce_state.py index 13672ebc7082..cab40ccbedf6 100644 --- a/tests/components/input_select/test_reproduce_state.py +++ b/tests/components/input_select/test_reproduce_state.py @@ -2,6 +2,7 @@ import pytest +from homeassistant.components.input_select import DOMAIN from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.state import async_reproduce_state @@ -27,7 +28,7 @@ async def test_reproducing_states( # Setup entity assert await async_setup_component( hass, - "input_select", + DOMAIN, { "input_select": { "test_select": {"options": VALID_OPTION_SET1, "initial": VALID_OPTION1} diff --git a/tests/components/input_text/test_init.py b/tests/components/input_text/test_init.py index c0c18a5153c8..393e275aa92e 100644 --- a/tests/components/input_text/test_init.py +++ b/tests/components/input_text/test_init.py @@ -232,7 +232,7 @@ async def test_input_text_context( ) -> None: """Test that input_text context works.""" assert await async_setup_component( - hass, "input_text", {"input_text": {"t1": {"initial": "bla"}}} + hass, DOMAIN, {"input_text": {"t1": {"initial": "bla"}}} ) state = hass.states.get("input_text.t1") diff --git a/tests/components/input_text/test_reproduce_state.py b/tests/components/input_text/test_reproduce_state.py index 88b8131000b4..85f6da7bdce9 100644 --- a/tests/components/input_text/test_reproduce_state.py +++ b/tests/components/input_text/test_reproduce_state.py @@ -2,6 +2,7 @@ import pytest +from homeassistant.components.input_text import DOMAIN from homeassistant.core import HomeAssistant, State from homeassistant.helpers.state import async_reproduce_state from homeassistant.setup import async_setup_component @@ -20,7 +21,7 @@ async def test_reproducing_states( # Setup entity for testing assert await async_setup_component( hass, - "input_text", + DOMAIN, { "input_text": { "test_text": {"min": "6", "max": "10", "initial": VALID_TEXT1} diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index 8fc854f17130..6d45674f63e9 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -14,6 +14,7 @@ from homeassistant.components.cover import ( CoverState, ) from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.intent import DOMAIN from homeassistant.components.lock import SERVICE_LOCK, SERVICE_UNLOCK from homeassistant.components.valve import ( DOMAIN as VALVE_DOMAIN, @@ -62,7 +63,7 @@ async def test_http_handle_intent( intent.async_register(hass, TestIntentHandler()) - result = await async_setup_component(hass, "intent", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_client() @@ -117,7 +118,7 @@ async def test_http_language_device_satellite_id( intent.async_register(hass, TestIntentHandler()) - result = await async_setup_component(hass, "intent", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result client = await hass_client() @@ -158,7 +159,7 @@ async def test_http_handle_intent_match_failure( ) -> None: """Test handle intent match failure via HTTP API.""" - assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, DOMAIN, {}) hass.states.async_set( "cover.garage_door_1", "closed", {ATTR_FRIENDLY_NAME: "Garage Door"} @@ -185,7 +186,7 @@ async def test_http_assistant( """Test handle intent only targets exposed entities with 'assistant' set.""" assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, DOMAIN, {}) hass.states.async_set( "cover.garage_door_1", "closed", {ATTR_FRIENDLY_NAME: "Garage Door 1"} @@ -235,7 +236,7 @@ async def test_http_assistant( async def test_cover_intents_loading(hass: HomeAssistant) -> None: """Test Cover Intents Loading.""" - assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, DOMAIN, {}) with pytest.raises(intent.UnknownIntent): await intent.async_handle( @@ -263,7 +264,7 @@ async def test_cover_intents_loading(hass: HomeAssistant) -> None: async def test_turn_on_intent(hass: HomeAssistant) -> None: """Test HassTurnOn intent.""" result = await async_setup_component(hass, "homeassistant", {}) - result = await async_setup_component(hass, "intent", {}) + result = await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert result @@ -287,7 +288,7 @@ async def test_turn_on_intent_button( hass: HomeAssistant, entity_registry: er.EntityRegistry, domain ) -> None: """Test HassTurnOn intent on button domains.""" - assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, DOMAIN, {}) button = entity_registry.async_get_or_create(domain, "test", "button_uid") @@ -314,7 +315,7 @@ async def test_turn_on_off_intent_valve( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test HassTurnOn/Off intent on valve domains.""" - assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, DOMAIN, {}) valve = entity_registry.async_get_or_create("valve", "test", "valve_uid") @@ -347,7 +348,7 @@ async def test_turn_on_off_intent_cover( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test HassTurnOn/Off intent on cover domains.""" - assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, DOMAIN, {}) cover = entity_registry.async_get_or_create("cover", "test", "cover_uid") @@ -380,7 +381,7 @@ async def test_turn_on_off_intent_lock( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test HassTurnOn/Off intent on lock domains.""" - assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, DOMAIN, {}) lock = entity_registry.async_get_or_create("lock", "test", "lock_uid") @@ -412,7 +413,7 @@ async def test_turn_on_off_intent_lock( async def test_turn_off_intent(hass: HomeAssistant) -> None: """Test HassTurnOff intent.""" result = await async_setup_component(hass, "homeassistant", {}) - result = await async_setup_component(hass, "intent", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result hass.states.async_set("light.test_light", "on") @@ -433,7 +434,7 @@ async def test_turn_off_intent(hass: HomeAssistant) -> None: async def test_toggle_intent(hass: HomeAssistant) -> None: """Test HassToggle intent.""" result = await async_setup_component(hass, "homeassistant", {}) - result = await async_setup_component(hass, "intent", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result hass.states.async_set("light.test_light", "off") @@ -457,7 +458,7 @@ async def test_turn_on_multiple_intent(hass: HomeAssistant) -> None: This tests that matching finds the proper entity among similar names. """ result = await async_setup_component(hass, "homeassistant", {}) - result = await async_setup_component(hass, "intent", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result hass.states.async_set("light.test_light", "off") @@ -480,7 +481,7 @@ async def test_turn_on_multiple_intent(hass: HomeAssistant) -> None: async def test_turn_on_all(hass: HomeAssistant) -> None: """Test HassTurnOn intent with "all" name.""" result = await async_setup_component(hass, "homeassistant", {}) - result = await async_setup_component(hass, "intent", {}) + result = await async_setup_component(hass, DOMAIN, {}) assert result hass.states.async_set("light.test_light", "off") @@ -516,7 +517,7 @@ async def test_get_state_intent( This tests name, area, domain, device class, and state constraints. """ assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, DOMAIN, {}) bedroom = area_registry.async_get_or_create("bedroom") kitchen = area_registry.async_get_or_create("kitchen") @@ -705,7 +706,7 @@ async def test_get_state_intent( async def test_set_position_intent_unsupported_domain(hass: HomeAssistant) -> None: """Test that HassSetPosition intent fails with unsupported domain.""" assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, DOMAIN, {}) # Can't set position of lights hass.states.async_set("light.test_light", "off") @@ -722,7 +723,7 @@ async def test_set_position_intent_unsupported_domain(hass: HomeAssistant) -> No async def test_intents_with_no_responses(hass: HomeAssistant) -> None: """Test intents that should not return a response during handling.""" assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, DOMAIN, {}) # The "respond" intent gets its response text from home-assistant-intents for intent_name in (intent.INTENT_NEVERMIND, intent.INTENT_RESPOND): @@ -733,7 +734,7 @@ async def test_intents_with_no_responses(hass: HomeAssistant) -> None: async def test_intents_respond_intent(hass: HomeAssistant) -> None: """Test HassRespond intent with a response slot value.""" assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, DOMAIN, {}) response = await intent.async_handle( hass, "test", intent.INTENT_RESPOND, {"response": {"value": "Hello World"}} @@ -743,7 +744,7 @@ async def test_intents_respond_intent(hass: HomeAssistant) -> None: async def test_stop_moving_valve(hass: HomeAssistant) -> None: """Test HassStopMoving intent for valves.""" - assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, DOMAIN, {}) entity_id = f"{VALVE_DOMAIN}.test_valve" hass.states.async_set(entity_id, ValveState.OPEN) @@ -771,7 +772,7 @@ async def test_stop_moving_valve(hass: HomeAssistant) -> None: ) async def test_stop_moving_cover(hass: HomeAssistant, slots: dict[str, Any]) -> None: """Test HassStopMoving intent for covers.""" - assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, DOMAIN, {}) entity_id = f"{COVER_DOMAIN}.test_cover" hass.states.async_set( @@ -793,7 +794,7 @@ async def test_stop_moving_cover(hass: HomeAssistant, slots: dict[str, Any]) -> async def test_stop_moving_intent_unsupported_domain(hass: HomeAssistant) -> None: """Test that HassStopMoving intent fails with unsupported domain.""" assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, DOMAIN, {}) # Can't stop lights hass.states.async_set("light.test_light", "on") diff --git a/tests/components/intent/test_temperature.py b/tests/components/intent/test_temperature.py index 936adb26e798..33fa0cd4643a 100644 --- a/tests/components/intent/test_temperature.py +++ b/tests/components/intent/test_temperature.py @@ -14,6 +14,7 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.intent import DOMAIN from homeassistant.components.sensor import SensorDeviceClass from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ATTR_DEVICE_CLASS, Platform, UnitOfTemperature @@ -141,7 +142,7 @@ async def test_get_temperature( ) -> None: """Test HassClimateGetTemperature intent.""" assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, DOMAIN, {}) climate_1 = MockClimateEntity() climate_1._attr_name = "Climate 1" @@ -421,7 +422,7 @@ async def test_get_temperature_no_entities( ) -> None: """Test HassClimateGetTemperature intent with no climate entities.""" assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, DOMAIN, {}) await create_mock_platform(hass, []) @@ -443,7 +444,7 @@ async def test_not_exposed( ) -> None: """Test HassClimateGetTemperature intent when entities aren't exposed.""" assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, DOMAIN, {}) climate_1 = MockClimateEntity() climate_1._attr_name = "Climate 1" diff --git a/tests/components/intent/test_timers.py b/tests/components/intent/test_timers.py index c233c82f9d96..8d0ee4e901ab 100644 --- a/tests/components/intent/test_timers.py +++ b/tests/components/intent/test_timers.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components import conversation +from homeassistant.components.intent import DOMAIN from homeassistant.components.intent.timers import ( TIMER_DATA, MultipleTimersMatchedError, @@ -37,7 +38,7 @@ async def init_components(hass: HomeAssistant) -> None: """Initialize required components for tests.""" assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "conversation", {}) - assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, DOMAIN, {}) async def test_start_finish_timer(hass: HomeAssistant, init_components) -> None: @@ -1682,7 +1683,7 @@ async def test_async_device_supports_timers(hass: HomeAssistant) -> None: assert not async_device_supports_timers(hass, device_id) # After intent initialization - assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert not async_device_supports_timers(hass, device_id) @callback diff --git a/tests/components/intent_script/test_init.py b/tests/components/intent_script/test_init.py index 08348f581aa8..49994bcc1e8f 100644 --- a/tests/components/intent_script/test_init.py +++ b/tests/components/intent_script/test_init.py @@ -26,7 +26,7 @@ async def test_intent_script(hass: HomeAssistant) -> None: await async_setup_component( hass, - "intent_script", + DOMAIN, { "intent_script": { "HelloWorld": { @@ -78,7 +78,7 @@ async def test_intent_script_wait_response(hass: HomeAssistant) -> None: await async_setup_component( hass, - "intent_script", + DOMAIN, { "intent_script": { "HelloWorldWaitResponse": { @@ -135,7 +135,7 @@ async def test_intent_script_service_response(hass: HomeAssistant) -> None: await async_setup_component( hass, - "intent_script", + DOMAIN, { "intent_script": { "HelloWorldServiceResponse": { @@ -165,7 +165,7 @@ async def test_intent_script_falsy_reprompt(hass: HomeAssistant) -> None: await async_setup_component( hass, - "intent_script", + DOMAIN, { "intent_script": { "HelloWorld": { @@ -220,7 +220,7 @@ async def test_intent_script_targets( await async_setup_component( hass, - "intent_script", + DOMAIN, { "intent_script": { "Targets": { @@ -333,7 +333,7 @@ async def test_intent_script_action_validation( await async_setup_component( hass, - "intent_script", + DOMAIN, { "intent_script": { "ChooseWithRegistryIdIntent": { @@ -430,7 +430,7 @@ async def test_reload(hass: HomeAssistant) -> None: config = {"intent_script": {"NewIntent1": {"speech": {"text": "HelloWorld123"}}}} - await async_setup_component(hass, "intent_script", config) + await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() intents = hass.data.get(intent.DATA_KEY) @@ -475,7 +475,7 @@ async def test_reload_unloads_scripts(hass: HomeAssistant) -> None: """Test that reloading intent scripts unloads the action scripts.""" await async_setup_component( hass, - "intent_script", + DOMAIN, { "intent_script": { "TestIntent": { diff --git a/tests/components/iotawatt/test_init.py b/tests/components/iotawatt/test_init.py index de3a2f9f829a..af4bc64cc54d 100644 --- a/tests/components/iotawatt/test_init.py +++ b/tests/components/iotawatt/test_init.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock import httpx +from homeassistant.components.iotawatt.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -18,7 +19,7 @@ async def test_setup_unload( ) -> None: """Test we can setup and unload an entry.""" mock_iotawatt.getSensors.return_value["sensors"]["my_sensor_key"] = INPUT_SENSOR - assert await async_setup_component(hass, "iotawatt", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert await hass.config_entries.async_unload(entry.entry_id) @@ -28,7 +29,7 @@ async def test_setup_connection_failed( ) -> None: """Test connection error during startup.""" mock_iotawatt.connect.side_effect = httpx.ConnectError("") - assert await async_setup_component(hass, "iotawatt", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY @@ -38,6 +39,6 @@ async def test_setup_auth_failed( ) -> None: """Test auth error during startup.""" mock_iotawatt.connect.return_value = False - assert await async_setup_component(hass, "iotawatt", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/iotawatt/test_sensor.py b/tests/components/iotawatt/test_sensor.py index 440c8f0bb71c..dbb11fe4258b 100644 --- a/tests/components/iotawatt/test_sensor.py +++ b/tests/components/iotawatt/test_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory +from homeassistant.components.iotawatt.const import DOMAIN from homeassistant.components.sensor import ( ATTR_STATE_CLASS, SensorDeviceClass, @@ -29,7 +30,7 @@ async def test_sensor_type_input( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_iotawatt: MagicMock ) -> None: """Test input sensors work.""" - assert await async_setup_component(hass, "iotawatt", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert len(hass.states.async_entity_ids()) == 0 @@ -67,7 +68,7 @@ async def test_sensor_type_output( mock_iotawatt.getSensors.return_value["sensors"]["my_watthour_sensor_key"] = ( OUTPUT_SENSOR ) - assert await async_setup_component(hass, "iotawatt", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert len(hass.states.async_entity_ids()) == 1 diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 33616d0bf511..e664e7293e81 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant import core from homeassistant.components import light +from homeassistant.components.light import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, CONF_PLATFORM, @@ -843,7 +844,7 @@ async def test_light_context( """Test that light context works.""" setup_test_component_platform(hass, light.DOMAIN, mock_light_entities) - assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + assert await async_setup_component(hass, DOMAIN, {"light": {"platform": "test"}}) await hass.async_block_till_done() state = hass.states.get("light.ceiling") @@ -871,7 +872,7 @@ async def test_light_turn_on_auth( """Test that light context works.""" setup_test_component_platform(hass, light.DOMAIN, mock_light_entities) - assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + assert await async_setup_component(hass, DOMAIN, {"light": {"platform": "test"}}) await hass.async_block_till_done() state = hass.states.get("light.ceiling") @@ -906,7 +907,7 @@ async def test_light_brightness_step(hass: HomeAssistant) -> None: entity1.supported_color_modes = {light.ColorMode.BRIGHTNESS} entity1.color_mode = light.ColorMode.BRIGHTNESS entity1.brightness = 50 - assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + assert await async_setup_component(hass, DOMAIN, {"light": {"platform": "test"}}) await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) @@ -950,7 +951,7 @@ async def test_light_brightness_step_pct(hass: HomeAssistant) -> None: entity.supported_color_modes = {light.ColorMode.BRIGHTNESS} entity.color_mode = light.ColorMode.BRIGHTNESS entity.brightness = 255 - assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + assert await async_setup_component(hass, DOMAIN, {"light": {"platform": "test"}}) await hass.async_block_till_done() state = hass.states.get(entity.entity_id) @@ -993,7 +994,7 @@ async def test_light_brightness_pct_conversion( entity.supported_color_modes = {light.ColorMode.BRIGHTNESS} entity.color_mode = light.ColorMode.BRIGHTNESS entity.brightness = 100 - assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + assert await async_setup_component(hass, DOMAIN, {"light": {"platform": "test"}}) await hass.async_block_till_done() state = hass.states.get(entity.entity_id) @@ -1148,7 +1149,7 @@ async def test_light_service_call_rgbw(hass: HomeAssistant) -> None: setup_test_component_platform(hass, light.DOMAIN, [entity0]) - assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + assert await async_setup_component(hass, DOMAIN, {"light": {"platform": "test"}}) await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) @@ -1188,7 +1189,7 @@ async def test_light_state_off(hass: HomeAssistant) -> None: entity3 = entities[3] entity3.supported_color_modes = {light.ColorMode.RGBW} - assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + assert await async_setup_component(hass, DOMAIN, {"light": {"platform": "test"}}) await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) @@ -1251,7 +1252,7 @@ async def test_light_state_rgbw(hass: HomeAssistant) -> None: entity0.rgbww_color = "Invalid" # Should be ignored entity0.xy_color = "Invalid" # Should be ignored - assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + assert await async_setup_component(hass, DOMAIN, {"light": {"platform": "test"}}) await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) @@ -1282,7 +1283,7 @@ async def test_light_state_rgbww(hass: HomeAssistant) -> None: entity0.xy_color = "Invalid" # Should be ignored entity0.brightness = 255 - assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + assert await async_setup_component(hass, DOMAIN, {"light": {"platform": "test"}}) await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) @@ -1344,7 +1345,7 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None: entity6.supported_color_modes = {light.ColorMode.COLOR_TEMP} entity6.color_mode = light.ColorMode.COLOR_TEMP - assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + assert await async_setup_component(hass, DOMAIN, {"light": {"platform": "test"}}) await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) @@ -1753,7 +1754,7 @@ async def test_light_turn_on_rgb_color_is_plain_tuple( ] setup_test_component_platform(hass, light.DOMAIN, entities) - assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + assert await async_setup_component(hass, DOMAIN, {"light": {"platform": "test"}}) await hass.async_block_till_done() await hass.services.async_call( @@ -1803,7 +1804,7 @@ async def test_light_service_call_color_temp_emulation(hass: HomeAssistant) -> N entity2.supported_color_modes = {light.ColorMode.HS, light.ColorMode.WHITE} entity2.color_mode = light.ColorMode.HS - assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + assert await async_setup_component(hass, DOMAIN, {"light": {"platform": "test"}}) await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) @@ -1864,7 +1865,7 @@ async def test_light_service_call_color_temp_conversion(hass: HomeAssistant) -> assert entity1.min_color_temp_kelvin == 2000 assert entity1.max_color_temp_kelvin == 6535 - assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + assert await async_setup_component(hass, DOMAIN, {"light": {"platform": "test"}}) await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) @@ -1982,7 +1983,7 @@ async def test_light_service_call_white_mode(hass: HomeAssistant) -> None: entity0.color_mode = light.ColorMode.HS setup_test_component_platform(hass, light.DOMAIN, [entity0]) - assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + assert await async_setup_component(hass, DOMAIN, {"light": {"platform": "test"}}) await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) @@ -2105,7 +2106,7 @@ async def test_light_state_color_conversion(hass: HomeAssistant) -> None: entity2.rgb_color = "Invalid" # Should be ignored entity2.xy_color = (0.1, 0.8) - assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + assert await async_setup_component(hass, DOMAIN, {"light": {"platform": "test"}}) await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 6eaf84d2423b..9a59151aaf09 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -16,6 +16,7 @@ from homeassistant.components import logbook, recorder # pylint: disable-next=home-assistant-component-root-import from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME from homeassistant.components.automation import EVENT_AUTOMATION_TRIGGERED +from homeassistant.components.logbook import DOMAIN from homeassistant.components.logbook.models import EventAsRow, LazyEventPartialState from homeassistant.components.logbook.processor import EventProcessor from homeassistant.components.logbook.queries.common import PSEUDO_EVENT_STATE_CHANGED @@ -137,7 +138,7 @@ async def test_service_call_create_logbook_entry_invalid_entity_id( hass: HomeAssistant, ) -> None: """Test if service call create log book entry with an invalid entity id.""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() hass.bus.async_fire( logbook.EVENT_LOGBOOK_ENTRY, @@ -356,7 +357,7 @@ async def test_logbook_view( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the logbook view.""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await async_recorder_block_till_done(hass) client = await hass_client() response = await client.get(f"/api/logbook/{dt_util.utcnow().isoformat()}") @@ -368,7 +369,7 @@ async def test_logbook_view_invalid_start_date_time( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the logbook view with an invalid date time.""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await async_recorder_block_till_done(hass) client = await hass_client() response = await client.get("/api/logbook/INVALID") @@ -380,7 +381,7 @@ async def test_logbook_view_invalid_end_date_time( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the logbook view.""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await async_recorder_block_till_done(hass) client = await hass_client() response = await client.get( @@ -395,7 +396,7 @@ async def test_logbook_view_period_entity( hass_client: ClientSessionGenerator, ) -> None: """Test the logbook view with period and entity.""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await async_recorder_block_till_done(hass) entity_id_test = "switch.test" @@ -499,7 +500,7 @@ async def test_logbook_describe_event( ), ) - assert await async_setup_component(hass, "logbook", {}) + assert await async_setup_component(hass, DOMAIN, {}) with freeze_time(dt_util.utcnow() - timedelta(seconds=5)): hass.bus.async_fire("some_event") await async_wait_recording_done(hass) @@ -605,7 +606,7 @@ async def test_logbook_view_end_time_entity( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the logbook view with end_time and entity.""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await async_recorder_block_till_done(hass) entity_id_test = "switch.test" @@ -753,7 +754,7 @@ async def test_logbook_entity_no_longer_in_state_machine( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test logbook view with entity removed from state machine.""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await async_setup_component(hass, "automation", {}) await async_setup_component(hass, "script", {}) @@ -794,7 +795,7 @@ async def test_filter_continuous_sensor_values( hass_client: ClientSessionGenerator, ) -> None: """Test remove continuous sensor events from logbook.""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await async_recorder_block_till_done(hass) entity_id_test = "switch.test" @@ -1488,7 +1489,7 @@ async def test_logbook_( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the logbook view with a single entity and .""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) assert await async_setup_component( hass, "template", @@ -1558,7 +1559,7 @@ async def test_logbook_many_entities_multiple_calls( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the logbook view with a many entities called multiple times.""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await async_setup_component(hass, "automation", {}) await async_recorder_block_till_done(hass) @@ -1631,7 +1632,7 @@ async def test_custom_log_entry_discoverable_via_( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test if a custom log entry is later discoverable via .""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await async_recorder_block_till_done(hass) logbook.async_log_entry( @@ -1669,7 +1670,7 @@ async def test_logbook_multiple_entities( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the logbook view with a multiple entities.""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) assert await async_setup_component( hass, "template", @@ -1794,7 +1795,7 @@ async def test_logbook_invalid_entity( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the logbook view with requesting an invalid entity.""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_client() @@ -1861,7 +1862,7 @@ async def test_fire_logbook_entries( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test many logbook entry calls.""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await async_recorder_block_till_done(hass) for _ in range(10): @@ -1910,7 +1911,7 @@ async def test_exclude_events_domain( logbook.DOMAIN: {CONF_EXCLUDE: {CONF_DOMAINS: ["switch", "alexa"]}}, } ) - await async_setup_component(hass, "logbook", config) + await async_setup_component(hass, DOMAIN, config) await async_recorder_block_till_done(hass) hass.bus.async_fire(EVENT_HOMEASSISTANT_START) @@ -1954,7 +1955,7 @@ async def test_exclude_events_domain_glob( ) await asyncio.gather( async_setup_component(hass, "homeassistant", {}), - async_setup_component(hass, "logbook", config), + async_setup_component(hass, DOMAIN, config), ) await async_recorder_block_till_done(hass) @@ -1999,7 +2000,7 @@ async def test_include_events_entity( ) await asyncio.gather( async_setup_component(hass, "homeassistant", {}), - async_setup_component(hass, "logbook", config), + async_setup_component(hass, DOMAIN, config), ) await async_recorder_block_till_done(hass) @@ -2037,7 +2038,7 @@ async def test_exclude_events_entity( ) await asyncio.gather( async_setup_component(hass, "homeassistant", {}), - async_setup_component(hass, "logbook", config), + async_setup_component(hass, DOMAIN, config), ) await async_recorder_block_till_done(hass) @@ -2076,7 +2077,7 @@ async def test_include_events_domain( ) await asyncio.gather( async_setup_component(hass, "homeassistant", {}), - async_setup_component(hass, "logbook", config), + async_setup_component(hass, DOMAIN, config), ) await async_recorder_block_till_done(hass) @@ -2125,7 +2126,7 @@ async def test_include_events_domain_glob( ) await asyncio.gather( async_setup_component(hass, "homeassistant", {}), - async_setup_component(hass, "logbook", config), + async_setup_component(hass, DOMAIN, config), ) await async_recorder_block_till_done(hass) @@ -2190,7 +2191,7 @@ async def test_include_exclude_events_no_globs( ) await asyncio.gather( async_setup_component(hass, "homeassistant", {}), - async_setup_component(hass, "logbook", config), + async_setup_component(hass, DOMAIN, config), ) await async_recorder_block_till_done(hass) @@ -2252,7 +2253,7 @@ async def test_include_exclude_events_with_glob_filters( ) await asyncio.gather( async_setup_component(hass, "homeassistant", {}), - async_setup_component(hass, "logbook", config), + async_setup_component(hass, DOMAIN, config), ) await async_recorder_block_till_done(hass) @@ -2304,7 +2305,7 @@ async def test_empty_config( ) await asyncio.gather( async_setup_component(hass, "homeassistant", {}), - async_setup_component(hass, "logbook", config), + async_setup_component(hass, DOMAIN, config), ) await async_recorder_block_till_done(hass) @@ -2329,7 +2330,7 @@ async def test_context_filter( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test we can filter by context.""" - assert await async_setup_component(hass, "logbook", {}) + assert await async_setup_component(hass, DOMAIN, {}) await async_recorder_block_till_done(hass) entity_id = "switch.blu" @@ -2528,7 +2529,7 @@ async def test_get_events_future_start_time( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test get_events with a future start time.""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await async_recorder_block_till_done(hass) future = dt_util.utcnow() + timedelta(hours=10) @@ -2554,7 +2555,7 @@ async def test_get_events_bad_start_time( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test get_events bad start time.""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await async_recorder_block_till_done(hass) client = await hass_ws_client() @@ -2576,7 +2577,7 @@ async def test_get_events_bad_end_time( ) -> None: """Test get_events bad end time.""" now = dt_util.utcnow() - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await async_recorder_block_till_done(hass) client = await hass_ws_client() @@ -2598,7 +2599,7 @@ async def test_get_events_invalid_filters( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test get_events invalid filters.""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await async_recorder_block_till_done(hass) client = await hass_ws_client() diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 6c6e714449e8..1bd6e840e212 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -12,7 +12,7 @@ import pytest from homeassistant import core from homeassistant.components import logbook, recorder from homeassistant.components.automation import ATTR_SOURCE, EVENT_AUTOMATION_TRIGGERED -from homeassistant.components.logbook import websocket_api +from homeassistant.components.logbook import DOMAIN, websocket_api from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.util import get_instance from homeassistant.components.script import EVENT_SCRIPT_STARTED @@ -372,7 +372,7 @@ async def test_get_events_future_start_time( recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test get_events with a future start time.""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await async_recorder_block_till_done(hass) future = dt_util.utcnow() + timedelta(hours=10) @@ -397,7 +397,7 @@ async def test_get_events_bad_start_time( recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test get_events bad start time.""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await async_recorder_block_till_done(hass) client = await hass_ws_client() @@ -418,7 +418,7 @@ async def test_get_events_bad_end_time( ) -> None: """Test get_events bad end time.""" now = dt_util.utcnow() - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await async_recorder_block_till_done(hass) client = await hass_ws_client() @@ -439,7 +439,7 @@ async def test_get_events_invalid_filters( recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test get_events invalid filters.""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await async_recorder_block_till_done(hass) client = await hass_ws_client() @@ -1968,7 +1968,7 @@ async def test_event_stream_bad_start_time( recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test event_stream bad start time.""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await async_recorder_block_till_done(hass) client = await hass_ws_client() @@ -2205,7 +2205,7 @@ async def test_event_stream_bad_end_time( recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test event_stream bad end time.""" - await async_setup_component(hass, "logbook", {}) + await async_setup_component(hass, DOMAIN, {}) await async_recorder_block_till_done(hass) utc_now = dt_util.utcnow() diff --git a/tests/components/logger/test_init.py b/tests/components/logger/test_init.py index 88bf7291ddc0..cb23a3d1e2b6 100644 --- a/tests/components/logger/test_init.py +++ b/tests/components/logger/test_init.py @@ -35,7 +35,7 @@ async def test_log_filtering( assert await async_setup_component( hass, - "logger", + DOMAIN, { "logger": { "default": "warning", @@ -104,7 +104,7 @@ async def test_setting_level(hass: HomeAssistant) -> None: with patch("logging.getLogger", mocks.__getitem__): assert await async_setup_component( hass, - "logger", + DOMAIN, { "logger": { "default": "warning", @@ -169,7 +169,7 @@ async def test_can_set_level_from_yaml(hass: HomeAssistant) -> None: assert await async_setup_component( hass, - "logger", + DOMAIN, { "logger": { "logs": { @@ -223,7 +223,7 @@ async def test_can_set_level_from_store( "key": "core.logger", "version": 1, } - assert await async_setup_component(hass, "logger", {}) + assert await async_setup_component(hass, DOMAIN, {}) await _assert_log_levels(hass) _reset_logging() @@ -336,7 +336,7 @@ async def test_can_set_integration_level_from_store( "key": "core.logger", "version": 1, } - assert await async_setup_component(hass, "logger", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert logging.getLogger(INTEGRATION_NS).isEnabledFor(logging.DEBUG) is False assert logging.getLogger(INTEGRATION_NS).isEnabledFor(logging.WARNING) is True @@ -363,7 +363,7 @@ async def test_chattier_log_level_wins_1( } assert await async_setup_component( hass, - "logger", + DOMAIN, { "logger": { "logs": { @@ -397,7 +397,7 @@ async def test_chattier_log_level_wins_2( "version": 1, } assert await async_setup_component( - hass, "logger", {"logger": {"logs": {INTEGRATION_NS: "debug"}}} + hass, DOMAIN, {"logger": {"logs": {INTEGRATION_NS: "debug"}}} ) assert logging.getLogger(INTEGRATION_NS).isEnabledFor(logging.DEBUG) is True @@ -421,7 +421,7 @@ async def test_log_once_removed_from_store( } hass_storage["core.logger"] = store_contents - assert await async_setup_component(hass, "logger", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert hass_storage["core.logger"]["data"] == store_contents["data"] @@ -438,7 +438,7 @@ async def test_services_require_admin( hass: HomeAssistant, hass_read_only_user: MockUser, service: str ) -> None: """Test logger services require admin.""" - assert await async_setup_component(hass, "logger", {}) + assert await async_setup_component(hass, DOMAIN, {}) with pytest.raises(Unauthorized): await hass.services.async_call( diff --git a/tests/components/logger/test_websocket_api.py b/tests/components/logger/test_websocket_api.py index b58b3d5ea59f..ead7ac59e135 100644 --- a/tests/components/logger/test_websocket_api.py +++ b/tests/components/logger/test_websocket_api.py @@ -4,6 +4,7 @@ import logging from unittest.mock import patch from homeassistant import config_entries, loader +from homeassistant.components.logger import DOMAIN from homeassistant.components.logger.helpers import DATA_LOGGER from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant @@ -24,7 +25,7 @@ async def test_integration_log_info( ) -> None: """Test fetching integration log info.""" - assert await async_setup_component(hass, "logger", {}) + assert await async_setup_component(hass, DOMAIN, {}) logging.getLogger("homeassistant.components.http").setLevel(logging.DEBUG) logging.getLogger("homeassistant.components.websocket_api").setLevel(logging.DEBUG) @@ -43,7 +44,7 @@ async def test_integration_log_info_discovered_flows( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_admin_user: MockUser ) -> None: """Test that log info includes discovered flows.""" - assert await async_setup_component(hass, "logger", {}) + assert await async_setup_component(hass, DOMAIN, {}) # Set up a discovery flow (zeroconf) mock_integration(hass, MockModule("discovered_integration")) @@ -108,7 +109,7 @@ async def test_integration_log_info_with_settings( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_admin_user: MockUser ) -> None: """Test that log info includes integrations with custom log settings.""" - assert await async_setup_component(hass, "logger", {}) + assert await async_setup_component(hass, DOMAIN, {}) # Set up a mock integration that is not loaded mock_integration(hass, MockModule("unloaded_integration")) @@ -164,7 +165,7 @@ async def test_integration_log_level( ) -> None: """Test setting integration log level.""" websocket_client = await hass_ws_client() - assert await async_setup_component(hass, "logger", {}) + assert await async_setup_component(hass, DOMAIN, {}) await websocket_client.send_json( { @@ -191,7 +192,7 @@ async def test_custom_integration_log_level( ) -> None: """Test setting integration log level.""" websocket_client = await hass_ws_client() - assert await async_setup_component(hass, "logger", {}) + assert await async_setup_component(hass, DOMAIN, {}) integration = loader.Integration( hass, @@ -243,7 +244,7 @@ async def test_integration_log_level_unknown_integration( ) -> None: """Test setting integration log level for an unknown integration.""" websocket_client = await hass_ws_client() - assert await async_setup_component(hass, "logger", {}) + assert await async_setup_component(hass, DOMAIN, {}) await websocket_client.send_json( { @@ -268,7 +269,7 @@ async def test_module_log_level( websocket_client = await hass_ws_client() assert await async_setup_component( hass, - "logger", + DOMAIN, {"logger": {"logs": {"homeassistant.components.other_component": "warning"}}}, ) @@ -300,7 +301,7 @@ async def test_module_log_level_override( websocket_client = await hass_ws_client() assert await async_setup_component( hass, - "logger", + DOMAIN, {"logger": {"logs": {"homeassistant.components.websocket_api": "warning"}}}, ) @@ -372,7 +373,7 @@ async def test_integration_log_level_requires_admin( hass_read_only_access_token: str, ) -> None: """Test setting integration log level requires admin.""" - assert await async_setup_component(hass, "logger", {}) + assert await async_setup_component(hass, DOMAIN, {}) websocket_client = await hass_ws_client(hass, hass_read_only_access_token) await websocket_client.send_json( @@ -396,7 +397,7 @@ async def test_module_log_level_requires_admin( hass_read_only_access_token: str, ) -> None: """Test setting module log level requires admin.""" - assert await async_setup_component(hass, "logger", {}) + assert await async_setup_component(hass, DOMAIN, {}) websocket_client = await hass_ws_client(hass, hass_read_only_access_token) await websocket_client.send_json( diff --git a/tests/components/lovelace/test_cast.py b/tests/components/lovelace/test_cast.py index 4bae319ae17b..795450b0a663 100644 --- a/tests/components/lovelace/test_cast.py +++ b/tests/components/lovelace/test_cast.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch import pytest -from homeassistant.components.lovelace import cast as lovelace_cast +from homeassistant.components.lovelace import DOMAIN, cast as lovelace_cast from homeassistant.components.media_player import MediaClass from homeassistant.core import HomeAssistant from homeassistant.core_config import async_process_ha_core_config @@ -44,7 +44,7 @@ async def mock_yaml_dashboard(hass: HomeAssistant) -> AsyncGenerator[None]: # Set up a YAML dashboard with 2 views. assert await async_setup_component( hass, - "lovelace", + DOMAIN, { "lovelace": { "dashboards": { @@ -101,7 +101,7 @@ async def test_root_object(hass: HomeAssistant) -> None: async def test_browse_media_error(hass: HomeAssistant) -> None: """Test browse media checks valid URL.""" - assert await async_setup_component(hass, "lovelace", {}) + assert await async_setup_component(hass, DOMAIN, {}) with pytest.raises(HomeAssistantError): await lovelace_cast.async_browse_media( diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py index 2aa88f6547b6..3eb565f136ba 100644 --- a/tests/components/lovelace/test_dashboard.py +++ b/tests/components/lovelace/test_dashboard.py @@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant.components import frontend -from homeassistant.components.lovelace import const, dashboard +from homeassistant.components.lovelace import DOMAIN, const, dashboard from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component @@ -36,7 +36,7 @@ async def test_lovelace_from_storage_new_installation( hass_storage: dict[str, Any], ) -> None: """Test new installation has default lovelace panel but no dashboard entry.""" - assert await async_setup_component(hass, "lovelace", {}) + assert await async_setup_component(hass, DOMAIN, {}) # Default lovelace panel is registered for backward compatibility assert "lovelace" in hass.data[frontend.DATA_PANELS] @@ -63,7 +63,7 @@ async def test_lovelace_from_storage_migration( "data": {"config": {"views": [{"title": "Home"}]}}, } - assert await async_setup_component(hass, "lovelace", {}) + assert await async_setup_component(hass, DOMAIN, {}) # After migration, lovelace panel should be registered as a dashboard assert "lovelace" in hass.data[frontend.DATA_PANELS] @@ -151,7 +151,7 @@ async def test_lovelace_dashboard_deleted_re_registers_panel( "data": {"config": {"views": [{"title": "Home"}]}}, } - assert await async_setup_component(hass, "lovelace", {}) + assert await async_setup_component(hass, DOMAIN, {}) # After migration, lovelace panel should be registered as a dashboard assert "lovelace" in hass.data[frontend.DATA_PANELS] @@ -201,7 +201,7 @@ async def test_lovelace_migration_completes_when_both_files_exist( } with patch("homeassistant.components.lovelace.os.rename") as mock_rename: - assert await async_setup_component(hass, "lovelace", {}) + assert await async_setup_component(hass, DOMAIN, {}) # Old file should be renamed as backup old_path = hass.config.path(".storage", dashboard.CONFIG_STORAGE_KEY_DEFAULT) @@ -258,7 +258,7 @@ async def test_lovelace_migration_skipped_when_already_migrated( "data": {"config": {"views": [{"title": "Old"}]}}, } - assert await async_setup_component(hass, "lovelace", {}) + assert await async_setup_component(hass, DOMAIN, {}) client = await hass_ws_client(hass) await client.send_json({"id": 5, "type": "lovelace/dashboards/list"}) @@ -276,7 +276,7 @@ async def test_lovelace_from_yaml( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test we load lovelace config from yaml.""" - assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}}) + assert await async_setup_component(hass, DOMAIN, {"lovelace": {"mode": "YAML"}}) assert hass.data[frontend.DATA_PANELS]["lovelace"].config == {"mode": "yaml"} client = await hass_ws_client(hass) @@ -366,7 +366,7 @@ async def test_lovelace_from_yaml_creates_repair_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test YAML mode creates a repair issue.""" - assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}}) + assert await async_setup_component(hass, DOMAIN, {"lovelace": {"mode": "YAML"}}) # Panel should be registered as a YAML dashboard assert hass.data[frontend.DATA_PANELS]["lovelace"].config == {"mode": "yaml"} @@ -387,7 +387,7 @@ async def test_dashboard_from_yaml( """Test we load lovelace dashboard config from yaml.""" assert await async_setup_component( hass, - "lovelace", + DOMAIN, { "lovelace": { "dashboards": { @@ -493,7 +493,7 @@ async def test_wrong_key_dashboard_from_yaml(hass: HomeAssistant) -> None: with assert_setup_component(0, "lovelace"): assert not await async_setup_component( hass, - "lovelace", + DOMAIN, { "lovelace": { "dashboards": { @@ -517,7 +517,7 @@ async def test_storage_dashboards( hass_storage: dict[str, Any], ) -> None: """Test we load lovelace config from storage.""" - assert await async_setup_component(hass, "lovelace", {}) + assert await async_setup_component(hass, DOMAIN, {}) # Default lovelace panel is registered for backward compatibility assert "lovelace" in hass.data[frontend.DATA_PANELS] @@ -684,7 +684,7 @@ async def test_websocket_list_dashboards( """Test listing dashboards both storage + YAML.""" assert await async_setup_component( hass, - "lovelace", + DOMAIN, { "lovelace": { "dashboards": { @@ -744,7 +744,7 @@ async def test_lovelace_migration_sets_default_panel( # Need to setup frontend to register the websocket commands assert await async_setup_component(hass, "frontend", {}) - assert await async_setup_component(hass, "lovelace", {}) + assert await async_setup_component(hass, DOMAIN, {}) # Verify default_panel was set in frontend system storage via websocket client = await hass_ws_client(hass) @@ -776,7 +776,7 @@ async def test_lovelace_migration_preserves_existing_default_panel( # Need to setup frontend to register the websocket commands assert await async_setup_component(hass, "frontend", {}) - assert await async_setup_component(hass, "lovelace", {}) + assert await async_setup_component(hass, DOMAIN, {}) # Verify default_panel was NOT overwritten via websocket client = await hass_ws_client(hass) @@ -795,7 +795,7 @@ async def test_lovelace_no_migration_no_default_panel_set( # Need to setup frontend to register the websocket commands assert await async_setup_component(hass, "frontend", {}) # No pre-existing lovelace storage = no migration - assert await async_setup_component(hass, "lovelace", {}) + assert await async_setup_component(hass, DOMAIN, {}) # Verify default_panel was NOT set via websocket client = await hass_ws_client(hass) @@ -809,7 +809,7 @@ async def test_lovelace_info_default( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test lovelace/info returns default resource_mode.""" - assert await async_setup_component(hass, "lovelace", {}) + assert await async_setup_component(hass, DOMAIN, {}) client = await hass_ws_client(hass) @@ -824,7 +824,7 @@ async def test_lovelace_info_yaml_resource_mode( ) -> None: """Test lovelace/info returns yaml resource_mode.""" assert await async_setup_component( - hass, "lovelace", {"lovelace": {"resource_mode": "yaml"}} + hass, DOMAIN, {"lovelace": {"resource_mode": "yaml"}} ) client = await hass_ws_client(hass) @@ -839,7 +839,7 @@ async def test_lovelace_info_yaml_mode_fallback( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test lovelace/info returns yaml resource_mode when mode is yaml.""" - assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "yaml"}}) + assert await async_setup_component(hass, DOMAIN, {"lovelace": {"mode": "yaml"}}) client = await hass_ws_client(hass) diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index 14df32c21c24..34fe1868b001 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch import pytest import voluptuous as vol -from homeassistant.components.lovelace import _validate_url_slug +from homeassistant.components.lovelace import DOMAIN, _validate_url_slug from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -52,7 +52,7 @@ async def test_create_dashboards_when_onboarded( """Test we don't create dashboards when onboarded.""" client = await hass_ws_client(hass) - assert await async_setup_component(hass, "lovelace", {}) + assert await async_setup_component(hass, DOMAIN, {}) # List dashboards await client.send_json_auto_id({"type": "lovelace/dashboards/list"}) @@ -71,7 +71,7 @@ async def test_create_dashboards_when_not_onboarded( """Test we automatically create dashboards when not onboarded.""" client = await hass_ws_client(hass) - assert await async_setup_component(hass, "lovelace", {}) + assert await async_setup_component(hass, DOMAIN, {}) # Call onboarding listener mock_add_onboarding_listener.mock_calls[0][1][1]() diff --git a/tests/components/lovelace/test_resources.py b/tests/components/lovelace/test_resources.py index 2b248161b3e8..9cdbe712405c 100644 --- a/tests/components/lovelace/test_resources.py +++ b/tests/components/lovelace/test_resources.py @@ -8,7 +8,7 @@ import uuid import pytest from homeassistant.components.lovelace import dashboard, resources -from homeassistant.components.lovelace.const import LOVELACE_DATA +from homeassistant.components.lovelace.const import DOMAIN, LOVELACE_DATA from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -26,7 +26,7 @@ async def test_yaml_resources( ) -> None: """Test defining resources in configuration.yaml.""" assert await async_setup_component( - hass, "lovelace", {"lovelace": {"mode": "yaml", "resources": RESOURCE_EXAMPLES}} + hass, DOMAIN, {"lovelace": {"mode": "yaml", "resources": RESOURCE_EXAMPLES}} ) client = await hass_ws_client(hass) @@ -47,9 +47,7 @@ async def test_yaml_resources_backwards( "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"resources": RESOURCE_EXAMPLES}, ): - assert await async_setup_component( - hass, "lovelace", {"lovelace": {"mode": "yaml"}} - ) + assert await async_setup_component(hass, DOMAIN, {"lovelace": {"mode": "yaml"}}) client = await hass_ws_client(hass) @@ -74,7 +72,7 @@ async def test_storage_resources( "version": 1, "data": {"items": resource_config}, } - assert await async_setup_component(hass, "lovelace", {}) + assert await async_setup_component(hass, DOMAIN, {}) client = await hass_ws_client(hass) @@ -93,7 +91,7 @@ async def test_storage_resources_import( list_cmd: str, ) -> None: """Test importing resources from storage config.""" - assert await async_setup_component(hass, "lovelace", {}) + assert await async_setup_component(hass, DOMAIN, {}) hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = { "key": "lovelace", "version": 1, @@ -259,7 +257,7 @@ async def test_storage_resources_import_invalid( list_cmd: str, ) -> None: """Test importing resources from storage config.""" - assert await async_setup_component(hass, "lovelace", {}) + assert await async_setup_component(hass, DOMAIN, {}) hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = { "key": "lovelace", "version": 1, @@ -296,7 +294,7 @@ async def test_storage_resources_create_preserves_existing( "version": 1, "data": {"items": resource_config}, } - assert await async_setup_component(hass, "lovelace", {}) + assert await async_setup_component(hass, DOMAIN, {}) resource_collection = hass.data[LOVELACE_DATA].resources @@ -329,7 +327,7 @@ async def test_storage_resources_safe_mode( "version": 1, "data": {"items": resource_config}, } - assert await async_setup_component(hass, "lovelace", {}) + assert await async_setup_component(hass, DOMAIN, {}) client = await hass_ws_client(hass) hass.config.safe_mode = True diff --git a/tests/components/lovelace/test_system_health.py b/tests/components/lovelace/test_system_health.py index 37318e42a280..62d0ab2e91b4 100644 --- a/tests/components/lovelace/test_system_health.py +++ b/tests/components/lovelace/test_system_health.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch import pytest -from homeassistant.components.lovelace import dashboard +from homeassistant.components.lovelace import DOMAIN, dashboard from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -28,7 +28,7 @@ def mock_onboarding_done() -> Generator[MagicMock]: async def test_system_health_info_autogen(hass: HomeAssistant) -> None: """Test system health info endpoint.""" - assert await async_setup_component(hass, "lovelace", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert await async_setup_component(hass, "system_health", {}) info = await get_system_health_info(hass, "lovelace") assert info == {"dashboards": 1, "mode": "auto-gen", "resources": 0} @@ -45,7 +45,7 @@ async def test_system_health_info_storage_migration( "version": 1, "data": {"config": {"resources": [], "views": []}}, } - assert await async_setup_component(hass, "lovelace", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() info = await get_system_health_info(hass, "lovelace") # After migration: default dashboard (auto-gen) + migrated @@ -56,7 +56,7 @@ async def test_system_health_info_storage_migration( async def test_system_health_info_yaml(hass: HomeAssistant) -> None: """Test system health info endpoint.""" assert await async_setup_component(hass, "system_health", {}) - assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}}) + assert await async_setup_component(hass, DOMAIN, {"lovelace": {"mode": "YAML"}}) await hass.async_block_till_done() with patch( "homeassistant.components.lovelace.dashboard.load_yaml_dict", @@ -70,7 +70,7 @@ async def test_system_health_info_yaml(hass: HomeAssistant) -> None: async def test_system_health_info_yaml_not_found(hass: HomeAssistant) -> None: """Test system health info endpoint.""" assert await async_setup_component(hass, "system_health", {}) - assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}}) + assert await async_setup_component(hass, DOMAIN, {"lovelace": {"mode": "YAML"}}) await hass.async_block_till_done() info = await get_system_health_info(hass, "lovelace") # 2 dashboards: default storage (None) + yaml "lovelace" dashboard diff --git a/tests/components/lutron/test_init.py b/tests/components/lutron/test_init.py index b71faa20f064..6d494eafd460 100644 --- a/tests/components/lutron/test_init.py +++ b/tests/components/lutron/test_init.py @@ -21,7 +21,7 @@ async def test_setup_entry( """Test setting up the integration.""" mock_config_entry.add_to_hass(hass) - assert await async_setup_component(hass, "lutron", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert mock_config_entry.runtime_data.client is mock_lutron @@ -42,7 +42,7 @@ async def test_unload_entry( """Test unloading the integration.""" mock_config_entry.add_to_hass(hass) - assert await async_setup_component(hass, "lutron", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert await hass.config_entries.async_unload(mock_config_entry.entry_id) @@ -111,7 +111,7 @@ async def test_unique_id_migration( # Trigger the integration setup. # The async_setup_entry logic will detect the legacy IDs in the registry # and update them to the new UUIDs provided by the mock_lutron fixture. - assert await async_setup_component(hass, "lutron", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() # Verify that the entity's unique ID has been updated to the new format. diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 31c2ea90a361..c5ef28a1f80a 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -12,6 +12,7 @@ from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_FILTER_CLASSES, ATTR_MEDIA_SEARCH_QUERY, + DOMAIN, BrowseMedia, MediaClass, MediaPlayerEnqueue, @@ -93,9 +94,7 @@ async def test_get_image_http( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator ) -> None: """Test get image via http command.""" - await async_setup_component( - hass, "media_player", {"media_player": {"platform": "demo"}} - ) + await async_setup_component(hass, DOMAIN, {"media_player": {"platform": "demo"}}) await hass.async_block_till_done() state = hass.states.get("media_player.bedroom") @@ -145,7 +144,7 @@ async def test_get_image_http_remote( return_value=True, ): await async_setup_component( - hass, "media_player", {"media_player": {"platform": "demo"}} + hass, DOMAIN, {"media_player": {"platform": "demo"}} ) await hass.async_block_till_done() @@ -209,7 +208,7 @@ async def test_get_image_http_log_credentials_redacted( url, ): await async_setup_component( - hass, "media_player", {"media_player": {"platform": "demo"}} + hass, DOMAIN, {"media_player": {"platform": "demo"}} ) await hass.async_block_till_done() @@ -236,9 +235,7 @@ async def test_get_async_get_browse_image( hass_ws_client: WebSocketGenerator, ) -> None: """Test get browse image.""" - await async_setup_component( - hass, "media_player", {"media_player": {"platform": "demo"}} - ) + await async_setup_component(hass, DOMAIN, {"media_player": {"platform": "demo"}}) await hass.async_block_till_done() entity_comp = hass.data.get("entity_components", {}).get("media_player") @@ -265,9 +262,7 @@ async def test_media_browse( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test browsing media.""" - await async_setup_component( - hass, "media_player", {"media_player": {"platform": "demo"}} - ) + await async_setup_component(hass, DOMAIN, {"media_player": {"platform": "demo"}}) await hass.async_block_till_done() client = await hass_ws_client(hass) @@ -335,9 +330,7 @@ async def test_media_browse( async def test_media_browse_service(hass: HomeAssistant) -> None: """Test browsing media using service call.""" - await async_setup_component( - hass, "media_player", {"media_player": {"platform": "demo"}} - ) + await async_setup_component(hass, DOMAIN, {"media_player": {"platform": "demo"}}) await hass.async_block_till_done() with patch( @@ -406,9 +399,7 @@ async def test_media_search( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test browsing media.""" - await async_setup_component( - hass, "media_player", {"media_player": {"platform": "demo"}} - ) + await async_setup_component(hass, DOMAIN, {"media_player": {"platform": "demo"}}) await hass.async_block_till_done() client = await hass_ws_client(hass) @@ -470,9 +461,7 @@ async def test_media_search( async def test_media_search_service(hass: HomeAssistant) -> None: """Test browsing media.""" - await async_setup_component( - hass, "media_player", {"media_player": {"platform": "demo"}} - ) + await async_setup_component(hass, DOMAIN, {"media_player": {"platform": "demo"}}) await hass.async_block_till_done() expected = [ BrowseMedia( @@ -517,9 +506,7 @@ async def test_media_search_service(hass: HomeAssistant) -> None: async def test_group_members_available_when_off(hass: HomeAssistant) -> None: """Test that group_members are still available when media_player is off.""" - await async_setup_component( - hass, "media_player", {"media_player": {"platform": "demo"}} - ) + await async_setup_component(hass, DOMAIN, {"media_player": {"platform": "demo"}}) await hass.async_block_till_done() await hass.services.async_call( @@ -548,9 +535,7 @@ async def test_group_members_available_when_off(hass: HomeAssistant) -> None: ) async def test_enqueue_rewrite(hass: HomeAssistant, input, expected) -> None: """Test that group_members are still available when media_player is off.""" - await async_setup_component( - hass, "media_player", {"media_player": {"platform": "demo"}} - ) + await async_setup_component(hass, DOMAIN, {"media_player": {"platform": "demo"}}) await hass.async_block_till_done() # Fake group support for DemoYoutubePlayer @@ -575,9 +560,7 @@ async def test_enqueue_rewrite(hass: HomeAssistant, input, expected) -> None: async def test_enqueue_alert_exclusive(hass: HomeAssistant) -> None: """Test that alert and enqueue cannot be used together.""" - await async_setup_component( - hass, "media_player", {"media_player": {"platform": "demo"}} - ) + await async_setup_component(hass, DOMAIN, {"media_player": {"platform": "demo"}}) await hass.async_block_till_done() with pytest.raises(vol.Invalid): @@ -615,9 +598,7 @@ async def test_get_async_get_browse_image_quoting( async_get_browse_image() should get called with the same string that is passed into get_browse_image_url(). """ - await async_setup_component( - hass, "media_player", {"media_player": {"platform": "demo"}} - ) + await async_setup_component(hass, DOMAIN, {"media_player": {"platform": "demo"}}) await hass.async_block_till_done() entity_comp = hass.data.get("entity_components", {}).get("media_player") @@ -639,9 +620,7 @@ async def test_get_async_get_browse_image_quoting( async def test_play_media_via_selector(hass: HomeAssistant) -> None: """Test play_media data under 'media' is remapped for backward compat.""" - await async_setup_component( - hass, "media_player", {"media_player": {"platform": "demo"}} - ) + await async_setup_component(hass, DOMAIN, {"media_player": {"platform": "demo"}}) await hass.async_block_till_done() # Fake group support for DemoYoutubePlayer diff --git a/tests/components/melissa/__init__.py b/tests/components/melissa/__init__.py index 870727eea8fe..3c22631efa3c 100644 --- a/tests/components/melissa/__init__.py +++ b/tests/components/melissa/__init__.py @@ -1,5 +1,6 @@ """Tests for the melissa component.""" +from homeassistant.components.melissa import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -8,5 +9,5 @@ VALID_CONFIG = {"melissa": {"username": "********", "password": "********"}} async def setup_integration(hass: HomeAssistant) -> None: """Set up the melissa integration in Home Assistant.""" - assert await async_setup_component(hass, "melissa", VALID_CONFIG) + assert await async_setup_component(hass, DOMAIN, VALID_CONFIG) await hass.async_block_till_done() diff --git a/tests/components/mill/test_init.py b/tests/components/mill/test_init.py index ad781a1d0404..7980873f30f3 100644 --- a/tests/components/mill/test_init.py +++ b/tests/components/mill/test_init.py @@ -4,6 +4,7 @@ import asyncio from unittest.mock import patch from homeassistant.components import mill +from homeassistant.components.mill import DOMAIN from homeassistant.components.mill.coordinator import MillDataUpdateCoordinator from homeassistant.components.recorder import Recorder from homeassistant.config_entries import ConfigEntryState @@ -30,7 +31,7 @@ async def test_setup_with_cloud_config( patch("mill.Mill.fetch_heater_and_sensor_data", return_value={}) as mock_fetch, patch("mill.Mill.connect", return_value=True) as mock_connect, ): - assert await async_setup_component(hass, "mill", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert len(mock_fetch.mock_calls) == 1 assert len(mock_connect.mock_calls) == 1 @@ -49,7 +50,7 @@ async def test_setup_with_cloud_config_fails( ) entry.add_to_hass(hass) with patch("mill.Mill.connect", return_value=False): - assert await async_setup_component(hass, "mill", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert entry.state is ConfigEntryState.SETUP_RETRY @@ -87,7 +88,7 @@ async def test_setup_with_old_cloud_config( patch("mill.Mill.fetch_heater_and_sensor_data", return_value={}), patch("mill.Mill.connect", return_value=True) as mock_connect, ): - assert await async_setup_component(hass, "mill", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert len(mock_connect.mock_calls) == 1 @@ -125,7 +126,7 @@ async def test_setup_with_local_config( }, ) as mock_connect, ): - assert await async_setup_component(hass, "mill", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert len(mock_fetch.mock_calls) == 1 assert len(mock_connect.mock_calls) == 1 @@ -155,7 +156,7 @@ async def test_unload_entry(recorder_mock: Recorder, hass: HomeAssistant) -> Non return_value=True, ), ): - assert await async_setup_component(hass, "mill", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert isinstance(entry.runtime_data, MillDataUpdateCoordinator) diff --git a/tests/components/my/test_init.py b/tests/components/my/test_init.py index 0bfb2b5b4526..ef9277d30a0c 100644 --- a/tests/components/my/test_init.py +++ b/tests/components/my/test_init.py @@ -2,7 +2,7 @@ from unittest import mock -from homeassistant.components.my import URL_PATH +from homeassistant.components.my import DOMAIN, URL_PATH from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -12,7 +12,7 @@ async def test_setup(hass: HomeAssistant) -> None: with mock.patch( "homeassistant.components.frontend.async_register_built_in_panel" ) as mock_register_panel: - assert await async_setup_component(hass, "my", {"foo": "bar"}) + assert await async_setup_component(hass, DOMAIN, {"foo": "bar"}) assert mock_register_panel.call_args == mock.call( hass, "my", frontend_url_path=URL_PATH ) diff --git a/tests/components/netatmo/test_diagnostics.py b/tests/components/netatmo/test_diagnostics.py index dde28fe3bc7c..258943d3aeae 100644 --- a/tests/components/netatmo/test_diagnostics.py +++ b/tests/components/netatmo/test_diagnostics.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, patch from syrupy.assertion import SnapshotAssertion from syrupy.filters import paths +from homeassistant.components.netatmo import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -39,7 +40,7 @@ async def test_entry_diagnostics( ) mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() - assert await async_setup_component(hass, "netatmo", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index 2c46cc4365c1..0ebbec87ed0d 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -78,7 +78,7 @@ async def test_setup_component( ) mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() - assert await async_setup_component(hass, "netatmo", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() @@ -125,7 +125,7 @@ async def test_setup_component_with_config( mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() assert await async_setup_component( - hass, "netatmo", {"netatmo": {"client_id": "123", "client_secret": "abc"}} + hass, DOMAIN, {"netatmo": {"client_id": "123", "client_secret": "abc"}} ) await hass.async_block_till_done() @@ -196,7 +196,7 @@ async def test_setup_without_https( ) mock_async_generate_url.return_value = "http://example.com" assert await async_setup_component( - hass, "netatmo", {"netatmo": {"client_id": "123", "client_secret": "abc"}} + hass, DOMAIN, {"netatmo": {"client_id": "123", "client_secret": "abc"}} ) await hass.async_block_till_done() @@ -239,7 +239,7 @@ async def test_setup_with_cloud( fake_post_request, hass ) assert await async_setup_component( - hass, "netatmo", {"netatmo": {"client_id": "123", "client_secret": "abc"}} + hass, DOMAIN, {"netatmo": {"client_id": "123", "client_secret": "abc"}} ) assert cloud.async_active_subscription(hass) is True assert cloud.async_is_connected(hass) is True @@ -310,7 +310,7 @@ async def test_setup_with_cloudhook(hass: HomeAssistant) -> None: ) mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() - assert await async_setup_component(hass, "netatmo", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert cloud.async_active_subscription(hass) is True assert ( @@ -354,7 +354,7 @@ async def test_setup_component_with_delay( patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["light"]), ): assert await async_setup_component( - hass, "netatmo", {"netatmo": {"client_id": "123", "client_secret": "abc"}} + hass, DOMAIN, {"netatmo": {"client_id": "123", "client_secret": "abc"}} ) await hass.async_block_till_done() @@ -423,7 +423,7 @@ async def test_setup_component_invalid_token_scope(hass: HomeAssistant) -> None: ) mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() - assert await async_setup_component(hass, "netatmo", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() @@ -477,7 +477,7 @@ async def test_setup_component_invalid_token( mock_session.return_value.async_ensure_token_valid.side_effect = ( fake_ensure_valid_token ) - assert await async_setup_component(hass, "netatmo", {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index ab06f25b9687..d54a4e2b5e68 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -770,7 +770,7 @@ async def test_websocket_network_url( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test the network/url websocket command.""" - assert await async_setup_component(hass, "network", {}) + assert await async_setup_component(hass, DOMAIN, {}) client = await hass_ws_client(hass) @@ -812,7 +812,7 @@ async def test_repair_docker_host_network_not_docker( ) -> None: """Test repair is not created when not in Docker.""" with patch("homeassistant.util.package.is_docker_env", return_value=False): - assert await async_setup_component(hass, "network", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert not issue_registry.async_get_issue(DOMAIN, "docker_host_network") @@ -827,7 +827,7 @@ async def test_repair_docker_host_network_with_host_networking( patch("homeassistant.util.package.is_docker_env", return_value=True), patch("homeassistant.components.network.Path.exists", return_value=True), ): - assert await async_setup_component(hass, "network", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert not issue_registry.async_get_issue(DOMAIN, "docker_host_network") @@ -844,7 +844,7 @@ async def test_repair_docker_host_network_without_host_networking( patch("homeassistant.util.package.is_docker_env", return_value=True), patch("homeassistant.components.network.Path.exists", return_value=False), ): - assert await async_setup_component(hass, "network", {}) + assert await async_setup_component(hass, DOMAIN, {}) assert (issue := issue_registry.async_get_issue(DOMAIN, "docker_host_network")) assert issue == snapshot diff --git a/tests/components/notify/test_legacy.py b/tests/components/notify/test_legacy.py index e0ad2cffd51d..ffd9979d795d 100644 --- a/tests/components/notify/test_legacy.py +++ b/tests/components/notify/test_legacy.py @@ -114,7 +114,7 @@ async def help_setup_notify( # Mock platform with service mock_notify_platform(hass, tmp_path, "test", async_get_service=async_get_service) # Setup the platform - await async_setup_component(hass, "notify", {"notify": [{"platform": "test"}]}) + await async_setup_component(hass, DOMAIN, {"notify": [{"platform": "test"}]}) await hass.async_block_till_done() # Return mock for assertion service calls @@ -193,9 +193,7 @@ async def test_invalid_platform( """Test service setup with an invalid platform.""" mock_notify_platform(hass, tmp_path, "testnotify1") # Setup the platform - await async_setup_component( - hass, "notify", {"notify": [{"platform": "testnotify1"}]} - ) + await async_setup_component(hass, DOMAIN, {"notify": [{"platform": "testnotify1"}]}) await hass.async_block_till_done() assert "Invalid notify platform" in caplog.text caplog.clear() @@ -303,9 +301,7 @@ async def test_reload_with_notify_builtin_platform_reload( await notify.async_reload(hass, "testnotify") # Setup the platform - await async_setup_component( - hass, "notify", {"notify": [{"platform": "testnotify"}]} - ) + await async_setup_component(hass, DOMAIN, {"notify": [{"platform": "testnotify"}]}) await hass.async_block_till_done() assert hass.services.has_service(notify.DOMAIN, "testnotify_a") assert hass.services.has_service(notify.DOMAIN, "testnotify_b") @@ -360,9 +356,7 @@ async def test_setup_platform_and_reload(hass: HomeAssistant, tmp_path: Path) -> ) # Setup the testnotify platform - await async_setup_component( - hass, "notify", {"notify": [{"platform": "testnotify"}]} - ) + await async_setup_component(hass, DOMAIN, {"notify": [{"platform": "testnotify"}]}) await hass.async_block_till_done() assert hass.services.has_service("testnotify", SERVICE_RELOAD) assert hass.services.has_service(notify.DOMAIN, "testnotify_a") @@ -474,7 +468,7 @@ async def test_setup_platform_before_notify_setup( ) # Setup the testnotify platform - setup_coro = async_setup_component(hass, "notify", hass_config) + setup_coro = async_setup_component(hass, DOMAIN, hass_config) load_task = asyncio.create_task(load_coro) setup_task = asyncio.create_task(setup_coro) @@ -541,7 +535,7 @@ async def test_setup_platform_after_notify_setup( ) # Setup the testnotify platform - setup_coro = async_setup_component(hass, "notify", hass_config) + setup_coro = async_setup_component(hass, DOMAIN, hass_config) setup_task = asyncio.create_task(setup_coro) load_task = asyncio.create_task(load_coro) diff --git a/tests/components/numato/test_binary_sensor.py b/tests/components/numato/test_binary_sensor.py index 7597d5c2f1ab..6715d317fb0e 100644 --- a/tests/components/numato/test_binary_sensor.py +++ b/tests/components/numato/test_binary_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pytest +from homeassistant.components.numato import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery @@ -25,7 +26,7 @@ async def test_failing_setups_no_entities( ) -> None: """When port setup fails, no entity shall be created.""" monkeypatch.setattr(numato_fixture.NumatoDeviceMock, "setup", mockup_raise) - assert await async_setup_component(hass, "numato", NUMATO_CFG) + assert await async_setup_component(hass, DOMAIN, NUMATO_CFG) await hass.async_block_till_done() for entity_id in MOCKUP_ENTITY_IDS: assert entity_id not in hass.states.async_entity_ids() @@ -38,7 +39,7 @@ async def test_setup_callbacks(hass: HomeAssistant, numato_fixture) -> None: NumatoModuleMock.NumatoDeviceMock, "add_event_detect" ) as mock_add_event_detect: numato_fixture.discover() - assert await async_setup_component(hass, "numato", NUMATO_CFG) + assert await async_setup_component(hass, DOMAIN, NUMATO_CFG) await hass.async_block_till_done() # wait until services are registered mock_add_event_detect.assert_called() @@ -56,7 +57,7 @@ async def test_hass_binary_sensor_notification( hass: HomeAssistant, numato_fixture ) -> None: """Test regular operations from within Home Assistant.""" - assert await async_setup_component(hass, "numato", NUMATO_CFG) + assert await async_setup_component(hass, DOMAIN, NUMATO_CFG) await hass.async_block_till_done() # wait until services are registered assert ( hass.states.get("binary_sensor.numato_binary_sensor_mock_port2").state == "on" @@ -100,7 +101,7 @@ async def test_binary_sensor_setup_no_notify( raise_notification_error, ): numato_fixture.discover() - assert await async_setup_component(hass, "numato", NUMATO_CFG) + assert await async_setup_component(hass, DOMAIN, NUMATO_CFG) await hass.async_block_till_done() # wait until services are registered assert all( diff --git a/tests/components/numato/test_init.py b/tests/components/numato/test_init.py index 4695265f37fb..d50924341694 100644 --- a/tests/components/numato/test_init.py +++ b/tests/components/numato/test_init.py @@ -4,6 +4,7 @@ from numato_gpio import NumatoGpioError import pytest from homeassistant.components import numato +from homeassistant.components.numato import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -19,7 +20,7 @@ async def test_setup_no_devices( without raising. """ monkeypatch.setattr(numato_fixture, "discover", mockup_return) - assert await async_setup_component(hass, "numato", NUMATO_CFG) + assert await async_setup_component(hass, DOMAIN, NUMATO_CFG) assert len(numato_fixture.devices) == 0 @@ -31,7 +32,7 @@ async def test_fail_setup_raising_discovery( Setup shall return False. """ monkeypatch.setattr(numato_fixture, "discover", mockup_raise) - assert not await async_setup_component(hass, "numato", NUMATO_CFG) + assert not await async_setup_component(hass, DOMAIN, NUMATO_CFG) await hass.async_block_till_done() @@ -82,7 +83,7 @@ async def test_invalid_port_number(hass: HomeAssistant, numato_fixture, config) port1_config = sensorports_cfg["1"] sensorports_cfg["one"] = port1_config del sensorports_cfg["1"] - assert not await async_setup_component(hass, "numato", config) + assert not await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() assert not numato_fixture.devices @@ -97,7 +98,7 @@ async def test_too_low_adc_port_number( sensorports_cfg = config["numato"]["devices"][0]["sensors"]["ports"] sensorports_cfg.update({0: {"name": "toolow"}}) - assert not await async_setup_component(hass, "numato", config) + assert not await async_setup_component(hass, DOMAIN, config) assert not numato_fixture.devices @@ -110,7 +111,7 @@ async def test_too_high_adc_port_number( """ sensorports_cfg = config["numato"]["devices"][0]["sensors"]["ports"] sensorports_cfg.update({8: {"name": "toohigh"}}) - assert not await async_setup_component(hass, "numato", config) + assert not await async_setup_component(hass, DOMAIN, config) assert not numato_fixture.devices @@ -123,7 +124,7 @@ async def test_invalid_adc_range_value_type( """ sensorports_cfg = config["numato"]["devices"][0]["sensors"]["ports"] sensorports_cfg["1"]["source_range"][0] = "zero" - assert not await async_setup_component(hass, "numato", config) + assert not await async_setup_component(hass, DOMAIN, config) assert not numato_fixture.devices @@ -136,7 +137,7 @@ async def test_invalid_adc_source_range_length( """ sensorports_cfg = config["numato"]["devices"][0]["sensors"]["ports"] sensorports_cfg["1"]["source_range"].append(42) - assert not await async_setup_component(hass, "numato", config) + assert not await async_setup_component(hass, DOMAIN, config) assert not numato_fixture.devices @@ -149,7 +150,7 @@ async def test_invalid_adc_source_range_order( """ sensorports_cfg = config["numato"]["devices"][0]["sensors"]["ports"] sensorports_cfg["1"]["source_range"] = [2, 1] - assert not await async_setup_component(hass, "numato", config) + assert not await async_setup_component(hass, DOMAIN, config) assert not numato_fixture.devices @@ -162,7 +163,7 @@ async def test_invalid_adc_destination_range_value_type( """ sensorports_cfg = config["numato"]["devices"][0]["sensors"]["ports"] sensorports_cfg["1"]["destination_range"][0] = "zero" - assert not await async_setup_component(hass, "numato", config) + assert not await async_setup_component(hass, DOMAIN, config) assert not numato_fixture.devices @@ -175,7 +176,7 @@ async def test_invalid_adc_destination_range_length( """ sensorports_cfg = config["numato"]["devices"][0]["sensors"]["ports"] sensorports_cfg["1"]["destination_range"].append(42) - assert not await async_setup_component(hass, "numato", config) + assert not await async_setup_component(hass, DOMAIN, config) assert not numato_fixture.devices @@ -188,5 +189,5 @@ async def test_invalid_adc_destination_range_order( """ sensorports_cfg = config["numato"]["devices"][0]["sensors"]["ports"] sensorports_cfg["1"]["destination_range"] = [2, 1] - assert not await async_setup_component(hass, "numato", config) + assert not await async_setup_component(hass, DOMAIN, config) assert not numato_fixture.devices diff --git a/tests/components/numato/test_sensor.py b/tests/components/numato/test_sensor.py index c652df9b086a..4f50b6487060 100644 --- a/tests/components/numato/test_sensor.py +++ b/tests/components/numato/test_sensor.py @@ -2,6 +2,7 @@ import pytest +from homeassistant.components.numato import DOMAIN from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery @@ -19,7 +20,7 @@ async def test_failing_setups_no_entities( ) -> None: """When port setup fails, no entity shall be created.""" monkeypatch.setattr(numato_fixture.NumatoDeviceMock, "setup", mockup_raise) - assert await async_setup_component(hass, "numato", NUMATO_CFG) + assert await async_setup_component(hass, DOMAIN, NUMATO_CFG) await hass.async_block_till_done() for entity_id in MOCKUP_ENTITY_IDS: assert entity_id not in hass.states.async_entity_ids() @@ -30,7 +31,7 @@ async def test_failing_sensor_update( ) -> None: """Test condition when a sensor update fails.""" monkeypatch.setattr(numato_fixture.NumatoDeviceMock, "adc_read", mockup_raise) - assert await async_setup_component(hass, "numato", NUMATO_CFG) + assert await async_setup_component(hass, DOMAIN, NUMATO_CFG) await hass.async_block_till_done() assert hass.states.get("sensor.numato_adc_mock_port1").state is STATE_UNKNOWN diff --git a/tests/components/numato/test_switch.py b/tests/components/numato/test_switch.py index a5967efc9b1a..19549e8cea6b 100644 --- a/tests/components/numato/test_switch.py +++ b/tests/components/numato/test_switch.py @@ -3,6 +3,7 @@ import pytest from homeassistant.components import switch +from homeassistant.components.numato import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -26,7 +27,7 @@ async def test_failing_setups_no_entities( ) -> None: """When port setup fails, no entity shall be created.""" monkeypatch.setattr(numato_fixture.NumatoDeviceMock, "setup", mockup_raise) - assert await async_setup_component(hass, "numato", NUMATO_CFG) + assert await async_setup_component(hass, DOMAIN, NUMATO_CFG) await hass.async_block_till_done() for entity_id in MOCKUP_ENTITY_IDS: assert entity_id not in hass.states.async_entity_ids() @@ -34,7 +35,7 @@ async def test_failing_setups_no_entities( async def test_regular_hass_operations(hass: HomeAssistant, numato_fixture) -> None: """Test regular operations from within Home Assistant.""" - assert await async_setup_component(hass, "numato", NUMATO_CFG) + assert await async_setup_component(hass, DOMAIN, NUMATO_CFG) await hass.async_block_till_done() # wait until services are registered await hass.services.async_call( switch.DOMAIN, @@ -82,7 +83,7 @@ async def test_failing_hass_operations( Switches remain in their initial 'off' state when the device can't be written to. """ - assert await async_setup_component(hass, "numato", NUMATO_CFG) + assert await async_setup_component(hass, DOMAIN, NUMATO_CFG) await hass.async_block_till_done() # wait until services are registered monkeypatch.setattr(numato_fixture.devices[0], "write", mockup_raise) diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index b9a0e9efb31c..8aadbfbd95e3 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -586,7 +586,7 @@ async def test_restore_number_save_state( ) setup_test_component_platform(hass, DOMAIN, [entity0]) - assert await async_setup_component(hass, "number", {"number": {"platform": "test"}}) + assert await async_setup_component(hass, DOMAIN, {"number": {"platform": "test"}}) await hass.async_block_till_done() # Trigger saving state @@ -659,7 +659,7 @@ async def test_restore_number_restore_state( ) setup_test_component_platform(hass, DOMAIN, [entity0]) - assert await async_setup_component(hass, "number", {"number": {"platform": "test"}}) + assert await async_setup_component(hass, DOMAIN, {"number": {"platform": "test"}}) await hass.async_block_till_done() assert hass.states.get(entity0.entity_id) @@ -751,7 +751,7 @@ async def test_custom_unit( ) setup_test_component_platform(hass, DOMAIN, [entity0]) - assert await async_setup_component(hass, "number", {"number": {"platform": "test"}}) + assert await async_setup_component(hass, DOMAIN, {"number": {"platform": "test"}}) await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) @@ -821,7 +821,7 @@ async def test_custom_unit_change( ) setup_test_component_platform(hass, DOMAIN, [entity0]) - assert await async_setup_component(hass, "number", {"number": {"platform": "test"}}) + assert await async_setup_component(hass, DOMAIN, {"number": {"platform": "test"}}) await hass.async_block_till_done() # Default unit conversion according to unit system @@ -882,7 +882,7 @@ async def test_translated_unit( setup_test_component_platform(hass, DOMAIN, [entity0]) assert await async_setup_component( - hass, "number", {"number": {"platform": "test"}} + hass, DOMAIN, {"number": {"platform": "test"}} ) await hass.async_block_till_done() @@ -916,7 +916,7 @@ async def test_translated_unit_with_native_unit_raises( setup_test_component_platform(hass, DOMAIN, [entity0]) assert await async_setup_component( - hass, "number", {"number": {"platform": "test"}} + hass, DOMAIN, {"number": {"platform": "test"}} ) await hass.async_block_till_done() # Setup fails so entity_id is None @@ -941,7 +941,7 @@ async def test_ambiguous_unit_of_measurement_compat( ) setup_test_component_platform(hass, DOMAIN, [entity0]) - assert await async_setup_component(hass, "number", {"number": {"platform": "test"}}) + assert await async_setup_component(hass, DOMAIN, {"number": {"platform": "test"}}) await hass.async_block_till_done() # Check compatible unit is applied From fbf14c63c023e1d2fa81bd47052f6fb6b0226a08 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:55:50 +0200 Subject: [PATCH 014/404] Fix incorrect use of Platform in atag (#173025) --- tests/components/atag/test_climate.py | 12 +++--------- tests/components/atag/test_water_heater.py | 7 +++---- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/tests/components/atag/test_climate.py b/tests/components/atag/test_climate.py index cb5dcf3d2faf..71b929157605 100644 --- a/tests/components/atag/test_climate.py +++ b/tests/components/atag/test_climate.py @@ -2,7 +2,6 @@ from unittest.mock import PropertyMock, patch -from homeassistant.components.atag import DOMAIN from homeassistant.components.atag.climate import PRESET_MAP from homeassistant.components.climate import ( ATTR_HVAC_ACTION, @@ -17,12 +16,7 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_TEMPERATURE, - STATE_UNKNOWN, - Platform, -) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -31,7 +25,7 @@ from . import UID, init_integration from tests.test_util.aiohttp import AiohttpClientMocker -CLIMATE_ID = f"{Platform.CLIMATE}.atag_thermostat_{DOMAIN}" +CLIMATE_ID = "climate.atag_thermostat_atag" async def test_climate( @@ -44,7 +38,7 @@ async def test_climate( assert entity_registry.async_is_registered(CLIMATE_ID) entity = entity_registry.async_get(CLIMATE_ID) - assert entity.unique_id == f"{UID}-{Platform.CLIMATE}" + assert entity.unique_id == f"{UID}-climate" assert hass.states.get(CLIMATE_ID).attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE diff --git a/tests/components/atag/test_water_heater.py b/tests/components/atag/test_water_heater.py index bb2ad2efe32b..a06f0802dcad 100644 --- a/tests/components/atag/test_water_heater.py +++ b/tests/components/atag/test_water_heater.py @@ -2,12 +2,11 @@ from unittest.mock import patch -from homeassistant.components.atag import DOMAIN from homeassistant.components.water_heater import ( DOMAIN as WATER_HEATER_DOMAIN, SERVICE_SET_TEMPERATURE, ) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -15,7 +14,7 @@ from . import UID, init_integration from tests.test_util.aiohttp import AiohttpClientMocker -WATER_HEATER_ID = f"{Platform.WATER_HEATER}.atag_thermostat_{DOMAIN}" +WATER_HEATER_ID = "water_heater.atag_thermostat_atag" async def test_water_heater( @@ -29,7 +28,7 @@ async def test_water_heater( assert entity_registry.async_is_registered(WATER_HEATER_ID) entry = entity_registry.async_get(WATER_HEATER_ID) - assert entry.unique_id == f"{UID}-{Platform.WATER_HEATER}" + assert entry.unique_id == f"{UID}-water_heater" async def test_setting_target_temperature( From 05088bf9913d9c369a018315e0d698d0ebf1f054 Mon Sep 17 00:00:00 2001 From: Jeef Date: Mon, 8 Jun 2026 09:11:03 -0600 Subject: [PATCH 015/404] Add initial quality scale for Weatherflow local (#166022) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Markus Tuominen <3738613+Markus98@users.noreply.github.com> --- .../components/weatherflow/quality_scale.yaml | 178 ++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/weatherflow/quality_scale.yaml diff --git a/homeassistant/components/weatherflow/quality_scale.yaml b/homeassistant/components/weatherflow/quality_scale.yaml new file mode 100644 index 000000000000..b486a8538376 --- /dev/null +++ b/homeassistant/components/weatherflow/quality_scale.yaml @@ -0,0 +1,178 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not register any service actions. + appropriate-polling: + status: exempt + comment: | + This integration is local push (iot_class: local_push). Devices emit + UDP messages and the integration reacts to them via UDP listener + callbacks (asyncio DatagramProtocol); there is no polling interval + to configure. + brands: done + common-modules: + status: todo + comment: | + The integration lacks a coordinator module. Device state management and + dispatcher wiring are handled inline in __init__.py and the platform + files. A coordinator.py (or equivalent) and cleaner separation of + concerns would satisfy this rule. + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not register any service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: + status: todo + comment: | + The external documentation page does not include removal/uninstall + instructions for this integration. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: + status: todo + comment: | + The integration uses _async_current_entries() to abort duplicate + setup in the config flow, but hassfest requires either + single_config_entry: true in the manifest or the modern + _async_abort_entries_match / async_set_unique_id pattern. + + # Silver + action-exceptions: + status: exempt + comment: This integration does not register any service actions. + config-entry-unloading: done + docs-configuration-parameters: + status: todo + comment: | + The external documentation page does not document configuration + parameters in detail. Basic setup is described but no parameter + reference table exists. + docs-installation-parameters: + status: todo + comment: | + The external documentation page does not include detailed installation + parameter documentation. + entity-unavailable: + status: todo + comment: | + The integration does not use a DataUpdateCoordinator. Entity availability + is set per-entity via _attr_available in _async_update_state(), but there + is no mechanism to mark all entities unavailable when the UDP listener + fails after initial setup (e.g. network goes away mid-session). + integration-owner: done + log-when-unavailable: + status: todo + comment: | + Without a coordinator the integration has no centralised logging when + the listener becomes unavailable. Individual entities do not log when + they transition to unavailable. A coordinator would satisfy this + automatically. + parallel-updates: + status: todo + comment: | + Neither sensor.py nor event.py define a PARALLEL_UPDATES constant. + Because these are push-based entities that never poll, PARALLEL_UPDATES + should be set to 0 in each platform file. + reauthentication-flow: + status: exempt + comment: | + This integration uses local UDP discovery and requires no credentials, + so reauthentication is not applicable. + test-coverage: + status: todo + comment: | + Only config-flow tests exist (tests/components/weatherflow/ + test_config_flow.py). There are no tests for the sensor or event + platform entities, nor for async_setup_entry / async_unload_entry + behaviour. Coverage is well below the 95% Silver threshold. + + # Gold + devices: done + diagnostics: + status: todo + comment: | + No diagnostics.py module exists. An async_get_config_entry_diagnostics + function should be added to aid debugging. + discovery-update-info: + status: exempt + comment: | + The integration discovers devices via local UDP broadcast. There is no + network-level discovery protocol (Zeroconf, SSDP, DHCP, etc.) that + would provide updated device information via the manifest discovery + keys, so this rule is not applicable. + discovery: + status: exempt + comment: | + WeatherFlow devices broadcast UDP packets on the local network. The + integration listens for these packets rather than using HA's discovery + infrastructure (Zeroconf, SSDP, DHCP). Discovery happens at the + listener level within the integration itself, not via manifest-declared + discovery protocols. + docs-data-update: todo + docs-examples: + status: todo + comment: No automation or dashboard usage examples are present in the docs. + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: + status: todo + comment: No troubleshooting section exists in the external documentation. + docs-use-cases: + status: todo + comment: No practical use-case examples are documented. + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: + status: todo + comment: | + The integration does not raise translated HomeAssistantError or + ServiceValidationError exceptions. Config flow form errors use + translation keys resolved via strings.json, but those are separate from + the translated exceptions covered by this rule. + icon-translations: done + reconfiguration-flow: + status: todo + comment: | + No async_step_reconfigure step is implemented in config_flow.py. + Although the integration takes no user-configurable parameters, a + reconfiguration step may still be expected at Gold level. + repair-issues: + status: exempt + comment: | + There are no identified scenarios in this integration that require + directing users to the repair dashboard. + stale-devices: + status: todo + comment: | + async_remove_config_entry_device is implemented, allowing manual removal + of devices. However, there is no automated mechanism to detect and clean + up stale devices (e.g. devices that were seen on a previous run but are + no longer broadcasting). Full stale-device handling should be added. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + pyweatherflowudp communicates via raw UDP sockets (asyncio DatagramProtocol), + not HTTP. There is no aiohttp.ClientSession involved, so websession + injection is not applicable. + strict-typing: + status: todo + comment: | + The integration is not listed in .strict-typing and has not been + validated under mypy strict mode. Type annotations should be audited + and the domain added to .strict-typing. diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 3373e9f6df9f..43d5a45cf3e2 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1003,7 +1003,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "watson_tts", "watttime", "waze_travel_time", - "weatherflow", "weatherflow_cloud", "weatherkit", "webmin", From d10ede226475006030c8643c2ed246f749b1d867 Mon Sep 17 00:00:00 2001 From: Crocmagnon Date: Mon, 8 Jun 2026 17:25:01 +0200 Subject: [PATCH 016/404] data grand lyon: list stops and lines in config flow (#173117) --- .../components/data_grand_lyon/config_flow.py | 140 ++++++++++++++--- .../components/data_grand_lyon/strings.json | 19 ++- tests/components/data_grand_lyon/conftest.py | 35 +++++ .../data_grand_lyon/test_config_flow.py | 141 ++++++++++++++++-- 4 files changed, 299 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/data_grand_lyon/config_flow.py b/homeassistant/components/data_grand_lyon/config_flow.py index ad234b066d86..3a6ebdfd5b39 100644 --- a/homeassistant/components/data_grand_lyon/config_flow.py +++ b/homeassistant/components/data_grand_lyon/config_flow.py @@ -5,7 +5,7 @@ import logging from typing import Any from aiohttp import ClientError, ClientResponseError -from data_grand_lyon_ha import DataGrandLyonClient +from data_grand_lyon_ha import DataGrandLyonClient, TclStop, find_tcl_stop_by_id import voluptuous as vol from homeassistant.config_entries import ( @@ -18,6 +18,12 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from .const import ( CONF_LINE, @@ -43,13 +49,6 @@ STEP_RECONFIGURE_SCHEMA = vol.Schema( } ) -STEP_STOP_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_LINE): str, - vol.Required(CONF_STOP_ID): vol.Coerce(int), - } -) - STEP_VELOV_STATION_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_STATION_ID): vol.Coerce(int), @@ -179,33 +178,126 @@ class DataGrandLyonConfigFlow(ConfigFlow, domain=DOMAIN): class StopSubentryFlowHandler(ConfigSubentryFlow): """Handle a subentry flow for adding a Data Grand Lyon stop.""" + def __init__(self) -> None: + """Initialize the flow.""" + self._stops: list[TclStop] = [] + self._selected_stop: TclStop | None = None + self._selected_stop_id: int | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: - """Handle the user step to add a new stop.""" - entry = self._get_entry() + """Pick a stop from the list fetched from the API, or enter one manually.""" + if not self._stops: + if error := await self._async_load_stops(): + return self.async_abort(reason=error) + errors: dict[str, str] = {} if user_input is not None: - line = user_input[CONF_LINE] - stop_id = user_input[CONF_STOP_ID] - unique_id = f"{line}_{stop_id}" + try: + stop_id = int(user_input[CONF_STOP_ID]) + except ValueError: + errors[CONF_STOP_ID] = "invalid_stop_id" + else: + self._selected_stop_id = stop_id + self._selected_stop = find_tcl_stop_by_id(self._stops, stop_id) + return await self.async_step_pick_line() - for subentry in entry.subentries.values(): - if subentry.unique_id == unique_id: - return self.async_abort(reason="already_configured") - - name = f"{line} - Stop {stop_id}" - return self.async_create_entry( - title=name, - data={CONF_LINE: line, CONF_STOP_ID: stop_id}, - unique_id=unique_id, + options = [ + SelectOptionDict(value=str(stop.id), label=_stop_label(stop)) + for stop in sorted( + self._stops, key=lambda s: (s.nom, s.commune or "", s.id or 0) ) - + ] + schema = vol.Schema( + { + vol.Required(CONF_STOP_ID): SelectSelector( + SelectSelectorConfig( + options=options, + mode=SelectSelectorMode.DROPDOWN, + sort=False, + custom_value=True, + ) + ) + } + ) return self.async_show_form( step_id="user", - data_schema=STEP_STOP_DATA_SCHEMA, + data_schema=schema, + errors=errors, ) + async def async_step_pick_line( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Pick a line from the selected stop's desserte, or enter one manually.""" + assert self._selected_stop_id is not None + if user_input is not None: + return self._create_stop( + line=user_input[CONF_LINE], stop_id=self._selected_stop_id + ) + + options = self._selected_stop.desserte if self._selected_stop else [] + schema = vol.Schema( + { + vol.Required(CONF_LINE): SelectSelector( + SelectSelectorConfig( + options=options, + mode=SelectSelectorMode.DROPDOWN, + custom_value=True, + ) + ) + } + ) + return self.async_show_form(step_id="pick_line", data_schema=schema) + + async def _async_load_stops(self) -> str | None: + """Fetch TCL stops from the API, returning an error key on failure.""" + entry = self._get_entry() + session = async_get_clientsession(self.hass) + client = DataGrandLyonClient( + session=session, + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + ) + try: + self._stops = await client.get_tcl_stops() + except ClientResponseError as err: + if err.status in (401, 403): + return "invalid_auth" + return "cannot_connect" + except ClientError, TimeoutError: + return "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error fetching Data Grand Lyon TCL stops") + return "unknown" + return None + + def _create_stop(self, line: str, stop_id: int) -> SubentryFlowResult: + """Create the stop subentry, aborting on duplicate.""" + entry = self._get_entry() + unique_id = f"{line}_{stop_id}" + for subentry in entry.subentries.values(): + if subentry.unique_id == unique_id: + return self.async_abort(reason="already_configured") + + return self.async_create_entry( + title=f"{line} - Stop {stop_id}", + data={CONF_LINE: line, CONF_STOP_ID: stop_id}, + unique_id=unique_id, + ) + + +def _stop_label(stop: TclStop) -> str: + label = stop.nom + # variable extracted to please codespell. + address = stop.adresse # codespell:ignore adresse + if address or stop.commune: + label += " (" + ", ".join(filter(None, [address, stop.commune])) + ")" + label += f" - {stop.id}" + + return label + class VelovStationSubentryFlowHandler(ConfigSubentryFlow): """Handle a subentry flow for adding a Vélo'v station.""" diff --git a/homeassistant/components/data_grand_lyon/strings.json b/homeassistant/components/data_grand_lyon/strings.json index 697fda1dd0fb..074c2bc149f8 100644 --- a/homeassistant/components/data_grand_lyon/strings.json +++ b/homeassistant/components/data_grand_lyon/strings.json @@ -46,17 +46,30 @@ "config_subentries": { "stop": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "entry_type": "Transit stop", + "error": { + "invalid_stop_id": "Stop ID must be a number." + }, "initiate_flow": { "user": "Add transit stop" }, "step": { + "pick_line": { + "data": { + "line": "Line" + } + }, "user": { "data": { - "line": "Line", - "stop_id": "Stop ID" + "stop_id": "Stop" + }, + "data_description": { + "stop_id": "Search by stop name, address or city, or enter a stop ID directly." } } } diff --git a/tests/components/data_grand_lyon/conftest.py b/tests/components/data_grand_lyon/conftest.py index 2a96662a7693..5cecae7c06d3 100644 --- a/tests/components/data_grand_lyon/conftest.py +++ b/tests/components/data_grand_lyon/conftest.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch from data_grand_lyon_ha import ( TclPassage, TclPassageType, + TclStop, VelovAvailabilityLevel, VelovBikeStandAvailability, VelovStation, @@ -50,6 +51,39 @@ MOCK_DEPARTURES = [ ), ] +MOCK_TCL_STOPS = [ + TclStop( + id=100, + gid=1100, + adresse="Place Bellecour", # codespell:ignore adresse + ascenseur=False, + commune="Lyon 2", + desserte=["C3", "27"], + escalator=False, + insee="69382", + last_update=datetime(2026, 4, 10, 0, 0), + lat=45.757, + lon=4.832, + nom="Bellecour", + pmr=True, + ), + TclStop( + id=200, + gid=1200, + adresse="Cours Lafayette", # codespell:ignore adresse + ascenseur=True, + commune="Lyon 3", + desserte=["C3", "T1"], + escalator=True, + insee="69383", + last_update=datetime(2026, 4, 10, 0, 0), + lat=45.763, + lon=4.846, + nom="Part-Dieu", + pmr=True, + ), +] + MOCK_VELOV_STATION = VelovStation( number=1001, name="Place Bellecour", @@ -147,5 +181,6 @@ def mock_tcl_client() -> Generator[AsyncMock]: ) as mock_cls: client = mock_cls.return_value client.get_tcl_passages.return_value = MOCK_DEPARTURES + client.get_tcl_stops.return_value = MOCK_TCL_STOPS client.get_velov_stations.return_value = [MOCK_VELOV_STATION] yield client diff --git a/tests/components/data_grand_lyon/test_config_flow.py b/tests/components/data_grand_lyon/test_config_flow.py index 06a0508b97b6..7f89de4423cf 100644 --- a/tests/components/data_grand_lyon/test_config_flow.py +++ b/tests/components/data_grand_lyon/test_config_flow.py @@ -19,6 +19,8 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import MOCK_TCL_STOPS + from tests.common import MockConfigEntry @@ -32,6 +34,16 @@ def mock_get_tcl_passages() -> Generator[AsyncMock]: yield mock +@pytest.fixture +def mock_get_tcl_stops() -> Generator[AsyncMock]: + """Mock get_tcl_stops in the stop subentry picker flow.""" + with patch( + "homeassistant.components.data_grand_lyon.config_flow.DataGrandLyonClient.get_tcl_stops", + return_value=MOCK_TCL_STOPS, + ) as mock: + yield mock + + # Main config flow tests @@ -274,11 +286,12 @@ async def test_reconfigure_flow_errors( @pytest.mark.parametrize("mock_subentries", [[]]) -async def test_stop_subentry_flow( +async def test_stop_subentry_picker_flow( hass: HomeAssistant, mock_config_entry: MockConfigEntry, + mock_get_tcl_stops: AsyncMock, ) -> None: - """Test adding a stop subentry.""" + """Test adding a stop subentry by picking a stop and a line from the lists.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -289,23 +302,33 @@ async def test_stop_subentry_flow( ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" + assert mock_get_tcl_stops.await_count == 1 result = await hass.config_entries.subentries.async_configure( result["flow_id"], - {CONF_LINE: "C3", CONF_STOP_ID: 456}, + {CONF_STOP_ID: "200"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pick_line" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_LINE: "T1"}, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "C3 - Stop 456" - assert result["data"] == {CONF_LINE: "C3", CONF_STOP_ID: 456} - assert result["unique_id"] == "C3_456" + assert result["title"] == "T1 - Stop 200" + assert result["data"] == {CONF_LINE: "T1", CONF_STOP_ID: 200} + assert result["unique_id"] == "T1_200" -async def test_stop_subentry_already_configured( +@pytest.mark.parametrize("mock_subentries", [[]]) +async def test_stop_subentry_custom_value_flow( hass: HomeAssistant, mock_config_entry: MockConfigEntry, + mock_get_tcl_stops: AsyncMock, ) -> None: - """Test stop subentry aborts if same line+stop already exists.""" + """Test adding a stop subentry by typing a stop ID not present in the list.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -314,16 +337,116 @@ async def test_stop_subentry_already_configured( (mock_config_entry.entry_id, SUBENTRY_TYPE_STOP), context={"source": config_entries.SOURCE_USER}, ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_STOP_ID: "456"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pick_line" result = await hass.config_entries.subentries.async_configure( result["flow_id"], - {CONF_LINE: "C3", CONF_STOP_ID: 100}, + {CONF_LINE: "C3"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "C3 - Stop 456" + assert result["data"] == {CONF_LINE: "C3", CONF_STOP_ID: 456} + assert result["unique_id"] == "C3_456" + + +@pytest.mark.parametrize("mock_subentries", [[]]) +async def test_stop_subentry_invalid_stop_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_get_tcl_stops: AsyncMock, +) -> None: + """Test typing a non-numeric stop ID re-renders the form with an error.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_STOP), + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_STOP_ID: "not-a-number"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {CONF_STOP_ID: "invalid_stop_id"} + + +async def test_stop_subentry_picker_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_get_tcl_stops: AsyncMock, +) -> None: + """Test picker stop subentry aborts if same line+stop already exists.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_STOP), + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_STOP_ID: "100"}, + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_LINE: "C3"}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" +@pytest.mark.parametrize( + ("side_effect", "reason"), + [ + ( + ClientResponseError(request_info=None, history=(), status=500), + "cannot_connect", + ), + ( + ClientResponseError(request_info=None, history=(), status=401), + "invalid_auth", + ), + (ClientConnectionError("boom"), "cannot_connect"), + (TimeoutError("boom"), "cannot_connect"), + (RuntimeError("boom"), "unknown"), + ], +) +@pytest.mark.parametrize("mock_subentries", [[]]) +async def test_stop_subentry_picker_load_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_get_tcl_stops: AsyncMock, + side_effect: Exception, + reason: str, +) -> None: + """Test picker aborts with the right reason when loading stops fails.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_get_tcl_stops.side_effect = side_effect + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_STOP), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + # Vélo'v station subentry tests From 18d17a53465a3015d62537e093de26a82db38d06 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 8 Jun 2026 17:45:24 +0200 Subject: [PATCH 017/404] Config entry migration error on downgrading (#173184) --- homeassistant/config_entries.py | 11 ++++ tests/test_config_entries.py | 97 +++++++++++++++++++++++++++++++-- 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 3310effb26d7..d27a1a102a74 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1151,6 +1151,17 @@ class ConfigEntry[_DataT = Any]: if same_major_version and self.minor_version == handler.MINOR_VERSION: return True + if self.version > handler.VERSION: + self.logger.error( + "Config entry %s for %s has version %s which is higher than the" + " current version %s", + self.title, + self.domain, + self.version, + handler.VERSION, + ) + return False + if not (integration := self._integration_for_domain): integration = await loader.async_get_integration(hass, self.domain) component = await integration.async_get_component() diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index f8571e5abd59..62ebc4de916f 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -303,7 +303,20 @@ async def test_call_async_migrate_entry( ) mock_platform(hass, "comp.config_flow", None) - with patch("homeassistant.config_entries.support_entry_unload", return_value=True): + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 3 + MINOR_VERSION = 1 + + async def async_step_user(self, user_input=None): + """Test user step.""" + return self.async_create_entry(title="title", data={}) + + with ( + mock_config_flow("comp", TestFlow), + patch("homeassistant.config_entries.support_entry_unload", return_value=True), + ): result = await async_setup_component(hass, "comp", {}) await hass.async_block_till_done() assert result @@ -337,7 +350,18 @@ async def test_call_async_migrate_entry_failure_false( ) mock_platform(hass, "comp.config_flow", None) - result = await async_setup_component(hass, "comp", {}) + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 3 + MINOR_VERSION = 1 + + async def async_step_user(self, user_input=None): + """Test user step.""" + return self.async_create_entry(title="title", data={}) + + with mock_config_flow("comp", TestFlow): + result = await async_setup_component(hass, "comp", {}) assert result assert len(mock_migrate_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 0 @@ -369,7 +393,18 @@ async def test_call_async_migrate_entry_failure_exception( ) mock_platform(hass, "comp.config_flow", None) - result = await async_setup_component(hass, "comp", {}) + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 3 + MINOR_VERSION = 1 + + async def async_step_user(self, user_input=None): + """Test user step.""" + return self.async_create_entry(title="title", data={}) + + with mock_config_flow("comp", TestFlow): + result = await async_setup_component(hass, "comp", {}) assert result assert len(mock_migrate_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 0 @@ -401,7 +436,18 @@ async def test_call_async_migrate_entry_failure_not_bool( ) mock_platform(hass, "comp.config_flow", None) - result = await async_setup_component(hass, "comp", {}) + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 3 + MINOR_VERSION = 1 + + async def async_step_user(self, user_input=None): + """Test user step.""" + return self.async_create_entry(title="title", data={}) + + with mock_config_flow("comp", TestFlow): + result = await async_setup_component(hass, "comp", {}) assert result assert len(mock_migrate_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 0 @@ -409,6 +455,49 @@ async def test_call_async_migrate_entry_failure_not_bool( assert not entry.supports_unload +async def test_migrate_from_higher_version_not_supported( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test migration fails when downgrading (higher version to lower version).""" + entry = MockConfigEntry(domain="comp", version=2, minor_version=1) + entry.add_to_hass(hass) + assert not entry.supports_unload + + mock_migrate_entry = AsyncMock(return_value=True) + mock_setup_entry = AsyncMock(return_value=True) + + mock_integration( + hass, + MockModule( + "comp", + async_setup_entry=mock_setup_entry, + async_migrate_entry=mock_migrate_entry, + ), + ) + mock_platform(hass, "comp.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user(self, user_input=None): + """Test user step.""" + return self.async_create_entry(title="title", data={}) + + with mock_config_flow("comp", TestFlow): + result = await async_setup_component(hass, "comp", {}) + assert result + assert len(mock_migrate_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + assert entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR + assert ( + "Config entry Mock Title for comp has version 2 which is higher than the current version 1" + in caplog.text + ) + + @pytest.mark.parametrize(("major_version", "minor_version"), [(2, 1), (2, 2)]) async def test_call_async_migrate_entry_failure_not_supported( hass: HomeAssistant, major_version: int, minor_version: int From 37e4f1ab322f5fea805541488d08ab8a30522c49 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 8 Jun 2026 16:52:22 +0100 Subject: [PATCH 018/404] Bump renault-api to 0.5.12 (#173289) --- homeassistant/components/renault/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index a11c1ad36d26..b82b37f1f41b 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "silver", - "requirements": ["renault-api==0.5.11"] + "requirements": ["renault-api==0.5.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 03253c21777f..4ed7f849a1e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2890,7 +2890,7 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.5.11 +renault-api==0.5.12 # homeassistant.components.renson renson-endura-delta==1.7.2 From e38e6ecec848fba7e6d45821d77a8764e9e5071d Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 8 Jun 2026 11:13:53 -0500 Subject: [PATCH 019/404] Mitigate TTS ResultStream leak in pipeline (#173290) --- .../components/assist_pipeline/pipeline.py | 15 +++- homeassistant/components/tts/__init__.py | 9 ++ .../assist_pipeline/test_pipeline.py | 83 +++++++++++++++++++ 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 10ec91dace20..65939a1aec5d 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1816,6 +1816,11 @@ class PipelineInput: await self.run.text_to_speech(tts_input) except PipelineError as err: + if self.run.tts_stream: + # Clean up TTS stream + self.run.tts_stream.delete() + self.run.tts_stream = None + self.run.process_event( PipelineEvent( PipelineEventType.ERROR, @@ -1885,15 +1890,17 @@ class PipelineInput: ): prepare_tasks.append(self.run.prepare_recognize_intent(self.session)) + if prepare_tasks: + await asyncio.gather(*prepare_tasks) + + # Do TTS prepare separately so we don't create a ResultStream if the + # pipeline is invalid. if ( start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.TTS) <= end_stage_index ): - prepare_tasks.append(self.run.prepare_text_to_speech()) - - if prepare_tasks: - await asyncio.gather(*prepare_tasks) + await self.run.prepare_text_to_speech() class PipelinePreferred(CollectionError): diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 7b957b3c8163..28bb17ffbb08 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -613,6 +613,10 @@ class ResultStream: async for chunk in converted_audio: yield chunk + def delete(self) -> None: + """Remove the result stream from the manager.""" + self._manager.async_delete_result_stream(self.token) + def _hash_options(options: dict) -> str: """Hashes an options dictionary.""" @@ -809,6 +813,11 @@ class SpeechManager: stream.last_used = monotonic() return stream + @callback + def async_delete_result_stream(self, token: str) -> None: + """Delete a result stream given a token.""" + self.token_to_stream.pop(token, None) + @callback def async_create_result_stream( self, diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index a9dc2bef202f..ff4430613080 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -2274,3 +2274,86 @@ async def test_stt_vad_enabled_based_on_audio_processing( # VAD should NOT be created when requires_external_vad is False mock_vad.assert_not_called() + + +async def test_invalid_pipeline_does_not_create_tts_stream( + hass: HomeAssistant, + mock_wake_word_provider_entity: MockWakeWordEntity, + init_components, +) -> None: + """Test that an invalid pipeline won't create a TTS ResultStream.""" + pipeline = async_get_pipeline(hass, None) + await async_update_pipeline(hass, pipeline, stt_engine="does-not-exist") + + async def audio_data() -> AsyncGenerator[bytes]: + yield make_10ms_chunk(b"not used") + + with patch.object( + mock_wake_word_provider_entity, + "async_process_audio_stream", + side_effect=assist_pipeline.error.WakeWordTimeoutError( + code="timeout", message="timeout" + ), + ): + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + context=Context(), + event_callback=lambda event: None, + stt_metadata=stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + start_stage=assist_pipeline.PipelineStage.STT, + end_stage=assist_pipeline.PipelineStage.TTS, + audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False), + ) + + assert len(hass.data[tts.DATA_TTS_MANAGER].token_to_stream) == 0 + + +async def test_pipeline_error_before_tts_does_not_leak_result_stream( + hass: HomeAssistant, + mock_wake_word_provider_entity: MockWakeWordEntity, + init_components, +) -> None: + """Test that a pipeline error before TTS will not leak a ResultStream.""" + + async def audio_data() -> AsyncGenerator[bytes]: + yield make_10ms_chunk(b"not used") + + with patch.object( + mock_wake_word_provider_entity, + "async_process_audio_stream", + side_effect=assist_pipeline.error.WakeWordTimeoutError( + code="timeout", message="timeout" + ), + ): + for i in range(10): + with patch("secrets.token_urlsafe", return_value=f"mocked-token-{i}"): + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + context=Context(), + event_callback=lambda event: None, + stt_metadata=stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + start_stage=assist_pipeline.PipelineStage.WAKE_WORD, + end_stage=assist_pipeline.PipelineStage.TTS, + wake_word_settings=assist_pipeline.WakeWordSettings( + audio_seconds_to_buffer=1.5 + ), + audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False), + ) + + assert len(hass.data[tts.DATA_TTS_MANAGER].token_to_stream) == 0 From 9b84fc9dba036c71a3f4bb3f63280584f2122140 Mon Sep 17 00:00:00 2001 From: cnico Date: Mon, 8 Jun 2026 20:17:00 +0200 Subject: [PATCH 020/404] Update dio-chacon-wifi-api to 1.3.0 (#173240) --- homeassistant/components/chacon_dio/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/chacon_dio/manifest.json b/homeassistant/components/chacon_dio/manifest.json index 117982a7ab8f..da5260fa25bc 100644 --- a/homeassistant/components/chacon_dio/manifest.json +++ b/homeassistant/components/chacon_dio/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/chacon_dio", "iot_class": "cloud_push", "loggers": ["dio_chacon_api"], - "requirements": ["dio-chacon-wifi-api==1.2.2"] + "requirements": ["dio-chacon-wifi-api==1.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4ed7f849a1e1..9cb747998d9a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -838,7 +838,7 @@ devolo-home-control-api==0.19.0 devolo-plc-api==1.5.1 # homeassistant.components.chacon_dio -dio-chacon-wifi-api==1.2.2 +dio-chacon-wifi-api==1.3.0 # homeassistant.components.directv directv==0.4.0 From 03b0b4ad8b2fbefc50a78b95fdde6cf86517da2e Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 8 Jun 2026 13:22:17 -0500 Subject: [PATCH 021/404] Allow inline number ranges for sentence triggers (#173111) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../components/assist_satellite/__init__.py | 9 ++++- .../components/conversation/trigger.py | 9 ++++- .../assist_satellite/test_entity.py | 18 ++++++++++ tests/components/conversation/test_trigger.py | 35 +++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index dcb0ea48b6b9..abc435f4a22e 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -3,6 +3,7 @@ from dataclasses import asdict import logging from pathlib import Path +import re from typing import Any from hassil.parse_expression import parse_sentence @@ -204,6 +205,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def has_no_punctuation(value: list[str]) -> list[str]: """Validate result does not contain punctuation.""" for sentence in value: + # Exclude {list_references} which may contain punctuation characters. + sentence = _remove_list_references(sentence) if ( PUNCTUATION_START.search(sentence) or PUNCTUATION_END.search(sentence) @@ -215,6 +218,11 @@ def has_no_punctuation(value: list[str]) -> list[str]: return value +def _remove_list_references(sentence: str) -> str: + """Remove {list_references} from a sentence for linting.""" + return re.sub(r"(? list[str]: """Validate result can be parsed by hassil.""" for sentence in value: @@ -222,7 +230,6 @@ def is_valid_sentence(value: list[str]) -> list[str]: parse_sentence(sentence) except ParseError as err: raise vol.Invalid(f"invalid sentence: {err}") from err - return value diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index 4ba877a781c7..2f73f7c33d8f 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -1,6 +1,7 @@ """Offer sentence based automation rules.""" from collections.abc import Awaitable, Callable +import re from typing import Any from hassil.parse_expression import parse_sentence @@ -33,6 +34,8 @@ TRIGGER_CALLBACK_TYPE = Callable[ def has_no_punctuation(value: list[str]) -> list[str]: """Validate result does not contain punctuation.""" for sentence in value: + # Exclude {list_references} which may contain punctuation characters. + sentence = _remove_list_references(sentence) if ( PUNCTUATION_START.search(sentence) or PUNCTUATION_END.search(sentence) @@ -44,6 +47,11 @@ def has_no_punctuation(value: list[str]) -> list[str]: return value +def _remove_list_references(sentence: str) -> str: + """Remove {list_references} from a sentence for linting.""" + return re.sub(r"(? list[str]: """Validate result can be parsed by hassil.""" for sentence in value: @@ -51,7 +59,6 @@ def is_valid_sentence(value: list[str]) -> list[str]: parse_sentence(sentence) except ParseError as err: raise vol.Invalid(f"invalid sentence: {err}") from err - return value diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 837d551f0650..59a7a9fb5098 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -884,6 +884,24 @@ async def test_start_conversation_default_preannounce( ), True, ), + ( + { + "answers": [ + { + "id": "jazz_with_volume", + "sentences": ["jazz at {1..100:volume} percent volume"], + }, + ], + "preannounce": False, + }, + "jazz at forty two percent volume", + AssistSatelliteAnswer( + id="jazz_with_volume", + sentence="jazz at forty two percent volume", + slots={"volume": 42}, + ), + False, + ), ], ) async def test_ask_question( diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index efd230fb8e8a..1f9e54a38a9d 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -711,3 +711,38 @@ async def test_trigger_with_device_id(hass: HomeAssistant) -> None: result.response.speech["plain"]["speech"] == "my_device - assist_satellite.my_satellite" ) + + +async def test_inline_range_list(hass: HomeAssistant) -> None: + """Test sentence trigger and response with an inline number range list.""" + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": ["set brightness to {0..100:brightness} percent"], + }, + "action": { + "set_conversation_response": "Brightness set to" + " {{trigger.slots.brightness|int}}" + " ({{trigger.details.brightness.text}}) percent", + }, + } + }, + ) + + service_response = await hass.services.async_call( + "conversation", + "process", + { + "text": "set brightness to forty two percent", + }, + blocking=True, + return_response=True, + ) + assert ( + service_response["response"]["speech"]["plain"]["speech"] + == "Brightness set to 42 (forty two) percent" + ) From 0672c940a4aad78707a5012a855ccd6ed15bf439 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 8 Jun 2026 11:36:54 -0700 Subject: [PATCH 022/404] Use roboorck device capabilities to determine which entities are supported (#173282) --- .../components/roborock/binary_sensor.py | 24 +- homeassistant/components/roborock/button.py | 14 +- homeassistant/components/roborock/sensor.py | 20 +- tests/components/roborock/conftest.py | 16 +- .../snapshots/test_binary_sensor.ambr | 204 ++++++++++++++++ .../roborock/snapshots/test_sensor.ambr | 218 ++++++++++++++++++ tests/components/roborock/test_button.py | 12 +- 7 files changed, 475 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index 1c117e5c0978..aa1b81df22d8 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -4,6 +4,8 @@ from collections.abc import Callable from dataclasses import dataclass from roborock.data import CleanFluidStatus, RoborockStateCode +from roborock.data.v1.v1_containers import StatusField, StatusV2 +from roborock.devices.traits.v1 import PropertiesApi from roborock.roborock_message import RoborockZeoProtocol from homeassistant.components.binary_sensor import ( @@ -38,6 +40,9 @@ class RoborockBinarySensorDescription(BinarySensorEntityDescription): is_dock_entity: bool = False """Whether this sensor is for the dock.""" + support_fn: Callable[[PropertiesApi], bool] = lambda _: True + """Function to determine if binary sensor is supported by the device.""" + @dataclass(frozen=True, kw_only=True) class RoborockBinarySensorDescriptionA01(BinarySensorEntityDescription): @@ -55,6 +60,9 @@ BINARY_SENSOR_DESCRIPTIONS = [ entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.dry_status, is_dock_entity=True, + support_fn=lambda api: api.device_features.is_field_supported( + StatusV2, StatusField.DRY_STATUS + ), ), RoborockBinarySensorDescription( key="water_box_carriage_status", @@ -62,6 +70,7 @@ BINARY_SENSOR_DESCRIPTIONS = [ device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.water_box_carriage_status, + support_fn=lambda api: api.device_features.is_support_water_mode, ), RoborockBinarySensorDescription( key="water_box_status", @@ -69,6 +78,7 @@ BINARY_SENSOR_DESCRIPTIONS = [ device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.water_box_status, + support_fn=lambda api: api.device_features.is_support_water_mode, ), RoborockBinarySensorDescription( key="water_shortage", @@ -76,6 +86,7 @@ BINARY_SENSOR_DESCRIPTIONS = [ device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.water_shortage_status, + support_fn=lambda api: api.device_features.is_support_water_mode, ), RoborockBinarySensorDescription( key="dirty_box_full", @@ -84,6 +95,7 @@ BINARY_SENSOR_DESCRIPTIONS = [ entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.dirty_water_box_status, is_dock_entity=True, + support_fn=lambda api: api.wash_towel_mode is not None, ), RoborockBinarySensorDescription( key="clean_box_empty", @@ -92,6 +104,7 @@ BINARY_SENSOR_DESCRIPTIONS = [ entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.clear_water_box_status, is_dock_entity=True, + support_fn=lambda api: api.wash_towel_mode is not None, ), RoborockBinarySensorDescription( key="clean_fluid_empty", @@ -104,6 +117,10 @@ BINARY_SENSOR_DESCRIPTIONS = [ else None ), is_dock_entity=True, + support_fn=lambda api: ( + api.wash_towel_mode is not None + and api.device_features.is_clean_fluid_delivery_supported + ), ), RoborockBinarySensorDescription( key="in_cleaning", @@ -157,12 +174,7 @@ async def async_setup_entry( ) for coordinator in config_entry.runtime_data.v1 for description in BINARY_SENSOR_DESCRIPTIONS - # Note: Currently coordinator.data is always available - # on startup but won't be in the future - if ( - coordinator.data is not None - and description.value_fn(coordinator.data) is not None - ) + if description.support_fn(coordinator.properties_api) ] entities.extend( RoborockBinarySensorEntityA01( diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index 0273cb89b4d0..6fe02dd01e1c 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -7,7 +7,6 @@ import itertools import logging from typing import Any -from roborock.device_features import is_wash_n_fill_dock from roborock.devices.traits.v1.consumeable import ConsumableAttribute from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockZeoProtocol @@ -47,11 +46,6 @@ class RoborockButtonDescription(ButtonEntityDescription): is_supported: Callable[[RoborockDataUpdateCoordinator], bool] = lambda _: True -def _supports_dock_consumables(coordinator: RoborockDataUpdateCoordinator) -> bool: - dock_type = coordinator.properties_api.status.dock_type - return dock_type is not None and is_wash_n_fill_dock(dock_type) - - CONSUMABLE_BUTTON_DESCRIPTIONS = [ RoborockButtonDescription( key="reset_sensor_consumable", @@ -88,7 +82,9 @@ CONSUMABLE_BUTTON_DESCRIPTIONS = [ entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, is_dock_entity=True, - is_supported=_supports_dock_consumables, + is_supported=lambda coordinator: ( + coordinator.properties_api.wash_towel_mode is not None + ), ), RoborockButtonDescription( key="reset_dock_cleaning_brush_consumable", @@ -97,7 +93,9 @@ CONSUMABLE_BUTTON_DESCRIPTIONS = [ entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, is_dock_entity=True, - is_supported=_supports_dock_consumables, + is_supported=lambda coordinator: ( + coordinator.properties_api.wash_towel_mode is not None + ), ), ] diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 049f07cce228..b6c9ff9f9f91 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -19,6 +19,7 @@ from roborock.data import ( ) from roborock.data.b01_q10.b01_q10_code_mappings import YXDeviceState from roborock.devices.traits.b01.q10.status import StatusTrait as Q10StatusTrait +from roborock.devices.traits.v1 import PropertiesApi from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol from homeassistant.components.sensor import ( @@ -64,6 +65,9 @@ class RoborockSensorDescription(SensorEntityDescription): # If it is a dock entity is_dock_entity: bool = False + support_fn: Callable[[PropertiesApi], bool] = lambda _: True + """Function to determine if sensor is supported by the device.""" + @dataclass(frozen=True, kw_only=True) class RoborockSensorDescriptionA01(SensorEntityDescription): @@ -131,6 +135,7 @@ SENSOR_DESCRIPTIONS = [ value_fn=lambda data: data.consumable.cleaning_brush_time_left, entity_category=EntityCategory.DIAGNOSTIC, is_dock_entity=True, + support_fn=lambda api: api.wash_towel_mode is not None, ), RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.HOURS, @@ -140,6 +145,7 @@ SENSOR_DESCRIPTIONS = [ value_fn=lambda data: data.consumable.strainer_time_left, entity_category=EntityCategory.DIAGNOSTIC, is_dock_entity=True, + support_fn=lambda api: api.wash_towel_mode is not None, ), RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, @@ -234,15 +240,14 @@ SENSOR_DESCRIPTIONS = [ entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.TIMESTAMP, ), - # Only available on some newer models RoborockSensorDescription( key="clean_percent", translation_key="clean_percent", value_fn=lambda data: data.status.clean_percent, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, + support_fn=lambda api: api.device_features.is_support_clean_estimate, ), - # Only available with more than just the basic dock RoborockSensorDescription( key="dock_error", translation_key="dock_error", @@ -251,6 +256,9 @@ SENSOR_DESCRIPTIONS = [ device_class=SensorDeviceClass.ENUM, options=RoborockDockErrorCode.keys(), is_dock_entity=True, + # Only available with more than just the basic dock. Dust collection + # mode is a proxy for any more complex dock type (e.g. Auto-empty). + support_fn=lambda api: api.dust_collection_mode is not None, ), RoborockSensorDescription( key="mop_clean_remaining", @@ -261,6 +269,7 @@ SENSOR_DESCRIPTIONS = [ translation_key="mop_drying_remaining_time", entity_category=EntityCategory.DIAGNOSTIC, is_dock_entity=True, + support_fn=lambda api: api.device_features.is_supported_drying, ), ] @@ -536,12 +545,7 @@ async def async_setup_entry( ) for coordinator in coordinators.v1 for description in SENSOR_DESCRIPTIONS - # Note: Currently coordinator.data is always available - # on startup but won't be in the future - if ( - coordinator.data is not None - and description.value_fn(coordinator.data) is not None - ) + if description.support_fn(coordinator.properties_api) ] entities.extend(RoborockCurrentRoom(coordinator) for coordinator in coordinators.v1) entities.extend( diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index b2a11b55bd95..6bae9d5ca813 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -7,7 +7,7 @@ import logging import pathlib import tempfile from typing import Any -from unittest.mock import AsyncMock, Mock, PropertyMock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch import pytest from roborock import ( @@ -41,6 +41,7 @@ from roborock.devices.traits.v1.clean_summary import CleanSummaryTrait from roborock.devices.traits.v1.command import CommandTrait from roborock.devices.traits.v1.common import V1TraitMixin from roborock.devices.traits.v1.consumeable import ConsumableTrait +from roborock.devices.traits.v1.device_features import DeviceFeaturesTrait from roborock.devices.traits.v1.do_not_disturb import DoNotDisturbTrait from roborock.devices.traits.v1.dust_collection_mode import DustCollectionModeTrait from roborock.devices.traits.v1.home import HomeTrait @@ -344,6 +345,18 @@ def make_home_trait( return home_trait +def make_device_features() -> Mock: + """Create fake device features.""" + device_features = MagicMock(spec=DeviceFeaturesTrait) + device_features.is_supported_drying = True + device_features.is_support_water_mode = True + device_features.is_clean_fluid_delivery_supported = True + device_features.is_support_clean_estimate = True + device_features.is_clean_route_setting_supported = True + device_features.is_field_supported.return_value = True + return device_features + + def create_v1_properties(network_info: NetworkInfo) -> AsyncMock: """Create v1 properties for each fake device.""" v1_properties = AsyncMock(spec=PropertiesApi) @@ -351,6 +364,7 @@ def create_v1_properties(network_info: NetworkInfo) -> AsyncMock: trait_spec=StatusTrait, dataclass_template=STATUS, ) + v1_properties.device_features = make_device_features() _fan_speed_mapping = {m.code: m.value for m in VacuumModes} _water_mode_mapping = {m.code: m.value for m in WaterModes} _mop_route_mapping = {m.code: m.value for m in CleanRoutes} diff --git a/tests/components/roborock/snapshots/test_binary_sensor.ambr b/tests/components/roborock/snapshots/test_binary_sensor.ambr index 7db892a18776..c50953eda695 100644 --- a/tests/components/roborock/snapshots/test_binary_sensor.ambr +++ b/tests/components/roborock/snapshots/test_binary_sensor.ambr @@ -152,6 +152,57 @@ 'state': 'on', }) # --- +# name: test_binary_sensors[binary_sensor.roborock_s7_2_dock_cleaning_fluid-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.roborock_s7_2_dock_cleaning_fluid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cleaning fluid', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cleaning fluid', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'clean_fluid_empty', + 'unique_id': 'clean_fluid_empty_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_2_dock_cleaning_fluid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Roborock S7 2 Dock Cleaning fluid', + }), + 'context': , + 'entity_id': 'binary_sensor.roborock_s7_2_dock_cleaning_fluid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensors[binary_sensor.roborock_s7_2_dock_dirty_water_box-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -203,6 +254,57 @@ 'state': 'on', }) # --- +# name: test_binary_sensors[binary_sensor.roborock_s7_2_dock_mop_drying-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.roborock_s7_2_dock_mop_drying', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Mop drying', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mop drying', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mop_drying_status', + 'unique_id': 'dry_status_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_2_dock_mop_drying-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Roborock S7 2 Dock Mop drying', + }), + 'context': , + 'entity_id': 'binary_sensor.roborock_s7_2_dock_mop_drying', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensors[binary_sensor.roborock_s7_2_mop_attached-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -509,6 +611,57 @@ 'state': 'on', }) # --- +# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_dock_cleaning_fluid-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.roborock_s7_maxv_dock_cleaning_fluid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cleaning fluid', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cleaning fluid', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'clean_fluid_empty', + 'unique_id': 'clean_fluid_empty_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_dock_cleaning_fluid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Roborock S7 MaxV Dock Cleaning fluid', + }), + 'context': , + 'entity_id': 'binary_sensor.roborock_s7_maxv_dock_cleaning_fluid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensors[binary_sensor.roborock_s7_maxv_dock_dirty_water_box-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -560,6 +713,57 @@ 'state': 'on', }) # --- +# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_dock_mop_drying-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.roborock_s7_maxv_dock_mop_drying', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Mop drying', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mop drying', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mop_drying_status', + 'unique_id': 'dry_status_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_dock_mop_drying-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Roborock S7 MaxV Dock Mop drying', + }), + 'context': , + 'entity_id': 'binary_sensor.roborock_s7_maxv_dock_mop_drying', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensors[binary_sensor.roborock_s7_maxv_mop_attached-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/roborock/snapshots/test_sensor.ambr b/tests/components/roborock/snapshots/test_sensor.ambr index 13d2b785b1d1..34c0fbaa0981 100644 --- a/tests/components/roborock/snapshots/test_sensor.ambr +++ b/tests/components/roborock/snapshots/test_sensor.ambr @@ -1624,6 +1624,57 @@ 'state': '21.0', }) # --- +# name: test_sensors[sensor.roborock_s7_2_cleaning_progress-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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_2_cleaning_progress', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cleaning progress', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cleaning progress', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'clean_percent', + 'unique_id': 'clean_percent_device_2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_cleaning_progress-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 2 Cleaning progress', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_2_cleaning_progress', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensors[sensor.roborock_s7_2_cleaning_time-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1877,6 +1928,64 @@ 'state': '235', }) # --- +# name: test_sensors[sensor.roborock_s7_2_dock_mop_drying_remaining_time-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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_2_dock_mop_drying_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Mop drying remaining time', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mop drying remaining time', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mop_drying_remaining_time', + 'unique_id': 'mop_clean_remaining_device_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_2_dock_mop_drying_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock S7 2 Dock Mop drying remaining time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_2_dock_mop_drying_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensors[sensor.roborock_s7_2_dock_strainer_time_left-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -2835,6 +2944,57 @@ 'state': '21.0', }) # --- +# name: test_sensors[sensor.roborock_s7_maxv_cleaning_progress-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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_maxv_cleaning_progress', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cleaning progress', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cleaning progress', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'clean_percent', + 'unique_id': 'clean_percent_abc123', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_cleaning_progress-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 MaxV Cleaning progress', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_maxv_cleaning_progress', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensors[sensor.roborock_s7_maxv_cleaning_time-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -3088,6 +3248,64 @@ 'state': '235', }) # --- +# name: test_sensors[sensor.roborock_s7_maxv_dock_mop_drying_remaining_time-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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_s7_maxv_dock_mop_drying_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Mop drying remaining time', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mop drying remaining time', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mop_drying_remaining_time', + 'unique_id': 'mop_clean_remaining_abc123', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_s7_maxv_dock_mop_drying_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock S7 MaxV Dock Mop drying remaining time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_s7_maxv_dock_mop_drying_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensors[sensor.roborock_s7_maxv_dock_strainer_time_left-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/roborock/test_button.py b/tests/components/roborock/test_button.py index d64366389b6a..ae1c3ee52379 100644 --- a/tests/components/roborock/test_button.py +++ b/tests/components/roborock/test_button.py @@ -4,7 +4,6 @@ from unittest.mock import Mock import pytest from roborock import RoborockException -from roborock.data import RoborockDockTypeCode from roborock.devices.traits.v1.consumeable import ConsumableAttribute from roborock.exceptions import RoborockTimeout from syrupy.assertion import SnapshotAssertion @@ -46,15 +45,8 @@ async def test_buttons( @pytest.fixture def non_wash_n_fill_dock(fake_vacuum: FakeDevice) -> None: - """Override dock_type to a non-wash-n-fill value so dock buttons are gated out.""" - status = fake_vacuum.v1_properties.status - original_refresh = status.refresh.side_effect - - async def patched_refresh() -> None: - await original_refresh() - status.dock_type = RoborockDockTypeCode.auto_empty_dock - - status.refresh.side_effect = patched_refresh + """Disable wash towel mode to indicate this device has no wash functions.""" + fake_vacuum.v1_properties.wash_towel_mode = None @pytest.mark.usefixtures("entity_registry_enabled_by_default") From 392c7f97c8bab92e0859b9460158a4aca4c0d43c Mon Sep 17 00:00:00 2001 From: Marcello <58506324+Marcello17@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:38:49 +0200 Subject: [PATCH 023/404] Add individual code owner for Fluss (#173276) --- CODEOWNERS | 4 ++-- homeassistant/components/fluss/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 714be2927bf7..4a4440818814 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -576,8 +576,8 @@ CLAUDE.md @home-assistant/core /tests/components/flo/ @dmulcahey /homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor /tests/components/flume/ @ChrisMandich @bdraco @jeeftor -/homeassistant/components/fluss/ @fluss -/tests/components/fluss/ @fluss +/homeassistant/components/fluss/ @fluss @Marcello17 +/tests/components/fluss/ @fluss @Marcello17 /homeassistant/components/flux_led/ @icemanch /tests/components/flux_led/ @icemanch /homeassistant/components/forecast_solar/ @klaasnicolaas @frenck diff --git a/homeassistant/components/fluss/manifest.json b/homeassistant/components/fluss/manifest.json index d420a0b82a4a..47713fc2eacf 100644 --- a/homeassistant/components/fluss/manifest.json +++ b/homeassistant/components/fluss/manifest.json @@ -1,7 +1,7 @@ { "domain": "fluss", "name": "Fluss+", - "codeowners": ["@fluss"], + "codeowners": ["@fluss", "@Marcello17"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fluss", "iot_class": "cloud_polling", From 5ffd772868459b8146dcd769071adf84111b70e8 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 8 Jun 2026 21:39:19 +0200 Subject: [PATCH 024/404] Fix homeassistant hardware unique id migration (#173258) --- .../homeassistant_connect_zbt2/__init__.py | 19 ++++- .../homeassistant_sky_connect/__init__.py | 19 ++++- .../homeassistant_connect_zbt2/test_init.py | 69 ++++++++++++++++++ .../homeassistant_sky_connect/test_init.py | 73 +++++++++++++++++++ 4 files changed, 178 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homeassistant_connect_zbt2/__init__.py b/homeassistant/components/homeassistant_connect_zbt2/__init__.py index 0d7edab7a529..166744eccc8b 100644 --- a/homeassistant/components/homeassistant_connect_zbt2/__init__.py +++ b/homeassistant/components/homeassistant_connect_zbt2/__init__.py @@ -135,7 +135,24 @@ async def async_migrate_entry( ) if canonical.entry_id != config_entry.entry_id: - # The canonical entry's migration will remove this duplicate. + if canonical.minor_version < 2: + # The canonical entry has not been migrated yet and its + # migration will remove this duplicate. + return False + + # The canonical entry is already fully migrated and will not run + # a migration that removes this duplicate, so remove it here. The + # entry can't remove itself while its setup lock is held, so + # schedule the removal instead. + _LOGGER.debug( + "Removing duplicate config entry %s for serial %s in favor of %s", + config_entry.entry_id, + serial_number, + canonical.entry_id, + ) + hass.async_create_task( + hass.config_entries.async_remove(config_entry.entry_id) + ) return False for duplicate in duplicates: diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index f39f42f15747..16e2ce377ed4 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -239,7 +239,24 @@ async def async_migrate_entry( ) if canonical.entry_id != config_entry.entry_id: - # The canonical entry's migration will remove this duplicate. + if canonical.minor_version < 5: + # The canonical entry has not been migrated yet and its + # migration will remove this duplicate. + return False + + # The canonical entry is already fully migrated and will not run + # a migration that removes this duplicate, so remove it here. The + # entry can't remove itself while its setup lock is held, so + # schedule the removal instead. + _LOGGER.warning( + "Removing duplicate config entry %s for serial %s in favor of %s", + config_entry.entry_id, + serial_number, + canonical.entry_id, + ) + hass.async_create_task( + hass.config_entries.async_remove(config_entry.entry_id) + ) return False for duplicate in duplicates: diff --git a/tests/components/homeassistant_connect_zbt2/test_init.py b/tests/components/homeassistant_connect_zbt2/test_init.py index 3626446a198f..d13147f328b4 100644 --- a/tests/components/homeassistant_connect_zbt2/test_init.py +++ b/tests/components/homeassistant_connect_zbt2/test_init.py @@ -234,6 +234,75 @@ async def test_config_entry_migration_v2_prefers_active_entry( assert active_entry.unique_id == serial_number +async def test_config_entry_migration_v2_removes_duplicates_of_migrated_entry( + hass: HomeAssistant, +) -> None: + """Test v1.2 migration removes duplicates of an already migrated entry. + + A migrated entry (minor version 2) never runs the migration again, so the + remaining minor version 1 duplicates have to remove themselves instead of + relying on the canonical entry's migration to remove them. + """ + serial_number = "E072A1D90104" + data = { + "device": "/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_E072A1D90104-if00", + "firmware": "spinel", + "firmware_version": ( + "SL-OPENTHREAD/2.7.2.0_GitHub-fb0446f53; EFR32; Feb 24 2026 00:58:55" + ), + "manufacturer": "Nabu Casa", + "pid": "831A", + "product": "ZBT-2", + "serial_number": serial_number, + "vid": "303A", + } + + older_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="303A:831A_E072A1D90104_Nabu Casa_ZBT-2 - Nabu Casa ZBT-2", + source="usb", + data=dict(data), + version=1, + minor_version=1, + ) + older_entry.add_to_hass(hass) + + duplicate_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="303A:831A_E072A1D90104_Nabu Casa_ZBT-2", + source="import", + data=dict(data), + version=1, + minor_version=1, + ) + duplicate_entry.add_to_hass(hass) + + migrated_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=serial_number, + source="import", + data=dict(data), + version=1, + minor_version=2, + ) + migrated_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_connect_zbt2.os.path.exists", + return_value=True, + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + remaining_entries = hass.config_entries.async_entries(DOMAIN) + assert len(remaining_entries) == 1 + assert remaining_entries[0].entry_id == migrated_entry.entry_id + assert remaining_entries[0].minor_version == 2 + assert remaining_entries[0].unique_id == serial_number + assert hass.config_entries.async_get_entry(older_entry.entry_id) is None + assert hass.config_entries.async_get_entry(duplicate_entry.entry_id) is None + + async def test_setup_fails_on_missing_usb_port(hass: HomeAssistant) -> None: """Test setup failing when the USB port is missing.""" diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index be6f7f5f96df..036ba964917e 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -306,6 +306,79 @@ async def test_config_entry_migration_v5_prefers_active_entry( assert active_entry.unique_id == serial_number +async def test_config_entry_migration_v5_removes_duplicates_of_migrated_entry( + hass: HomeAssistant, +) -> None: + """Test v1.5 migration removes duplicates of an already migrated entry. + + A migrated entry (minor version 5) never runs the migration again, so the + remaining minor version 4 duplicates have to remove themselves instead of + relying on the canonical entry's migration to remove them. + """ + serial_number = "9e2adbd75b8beb119fe564a0f320645d" + data = { + "description": "SkyConnect v1.0", + "device": ( + "/dev/serial/by-id/" + "usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0" + ), + "vid": "10C4", + "pid": "EA60", + "serial_number": serial_number, + "manufacturer": "Nabu Casa", + "product": "SkyConnect v1.0", + "firmware": "ezsp", + "firmware_version": "7.4.4.0", + } + + older_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=( + "10C4:EA60_9e2adbd75b8beb119fe564a0f320645d_Nabu Casa_SkyConnect v1.0" + ), + source="usb", + data=dict(data), + version=1, + minor_version=4, + ) + older_entry.add_to_hass(hass) + + duplicate_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="10C4:EA60_9e2adbd75b8beb119fe564a0f320645d_Nabu_Casa_SkyConnect", + source="import", + data=dict(data), + version=1, + minor_version=4, + ) + duplicate_entry.add_to_hass(hass) + + migrated_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=serial_number, + source="import", + data=dict(data), + version=1, + minor_version=5, + ) + migrated_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_sky_connect.os.path.exists", + return_value=True, + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + remaining = hass.config_entries.async_entries(DOMAIN) + assert len(remaining) == 1 + assert remaining[0].entry_id == migrated_entry.entry_id + assert remaining[0].minor_version == 5 + assert remaining[0].unique_id == serial_number + assert hass.config_entries.async_get_entry(older_entry.entry_id) is None + assert hass.config_entries.async_get_entry(duplicate_entry.entry_id) is None + + async def test_setup_fails_on_missing_usb_port(hass: HomeAssistant) -> None: """Test setup failing when the USB port is missing.""" From 7c33b953d30631a4e088069aacca4fa174fe97c0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 8 Jun 2026 21:57:25 +0200 Subject: [PATCH 025/404] Remove not needed guards for integration migrations from future versions (#173301) --- homeassistant/components/airos/__init__.py | 4 -- .../components/alexa_devices/__init__.py | 4 -- homeassistant/components/anova/__init__.py | 4 -- .../components/anthropic/__init__.py | 4 -- homeassistant/components/bsblan/__init__.py | 4 -- homeassistant/components/comelit/__init__.py | 4 -- .../components/derivative/__init__.py | 4 -- homeassistant/components/dnsip/__init__.py | 4 -- .../components/elevenlabs/__init__.py | 4 -- homeassistant/components/epson/__init__.py | 4 -- homeassistant/components/file/__init__.py | 3 -- .../components/fujitsu_fglair/__init__.py | 2 - homeassistant/components/fyta/__init__.py | 4 -- homeassistant/components/generic/__init__.py | 4 -- .../components/generic_hygrostat/__init__.py | 3 -- .../components/generic_thermostat/__init__.py | 3 -- homeassistant/components/goodwe/__init__.py | 4 -- .../__init__.py | 4 -- .../components/history_stats/__init__.py | 3 -- .../homeassistant_connect_zbt2/__init__.py | 4 -- .../homeassistant_sky_connect/__init__.py | 4 -- .../components/homematicip_cloud/__init__.py | 2 - .../components/integration/__init__.py | 3 -- .../islamic_prayer_times/__init__.py | 3 -- .../components/jewish_calendar/__init__.py | 4 -- .../components/lamarzocco/__init__.py | 3 -- .../components/litterrobot/__init__.py | 3 -- .../components/mold_indicator/__init__.py | 4 -- homeassistant/components/mqtt/__init__.py | 4 -- homeassistant/components/nina/__init__.py | 3 -- .../components/nmap_tracker/__init__.py | 4 -- homeassistant/components/ollama/__init__.py | 4 -- homeassistant/components/onedrive/__init__.py | 3 -- .../components/open_router/__init__.py | 3 -- .../openai_conversation/__init__.py | 4 -- homeassistant/components/overkiz/__init__.py | 2 - homeassistant/components/roborock/__init__.py | 3 -- .../components/russound_rio/__init__.py | 3 -- .../components/satel_integra/__init__.py | 4 -- homeassistant/components/scrape/__init__.py | 4 -- homeassistant/components/shelly/__init__.py | 3 +- homeassistant/components/smhi/__init__.py | 4 -- homeassistant/components/solarlog/__init__.py | 4 -- homeassistant/components/sql/__init__.py | 4 -- .../components/statistics/__init__.py | 3 -- .../components/suez_water/__init__.py | 3 -- .../swiss_public_transport/__init__.py | 4 -- .../components/switch_as_x/__init__.py | 3 -- .../components/system_bridge/__init__.py | 4 -- .../components/systemmonitor/__init__.py | 4 -- homeassistant/components/tedee/__init__.py | 3 -- .../components/telegram_bot/__init__.py | 4 -- homeassistant/components/template/__init__.py | 4 -- .../components/teslemetry/__init__.py | 3 -- .../components/threshold/__init__.py | 3 -- homeassistant/components/tradfri/__init__.py | 4 -- .../components/trafikverket_train/__init__.py | 4 -- homeassistant/components/trend/__init__.py | 3 -- .../components/unifiprotect/__init__.py | 3 -- .../components/utility_meter/__init__.py | 4 -- homeassistant/components/vicare/__init__.py | 2 - .../components/vodafone_station/__init__.py | 4 -- homeassistant/components/wled/__init__.py | 4 -- homeassistant/components/workday/__init__.py | 4 -- homeassistant/components/zha/__init__.py | 5 --- tests/components/mqtt/test_config_flow.py | 1 - tests/components/shelly/test_init.py | 44 ++++++++++++++----- 67 files changed, 34 insertions(+), 242 deletions(-) diff --git a/homeassistant/components/airos/__init__.py b/homeassistant/components/airos/__init__.py index fca7b7c2405e..105ec10ce11b 100644 --- a/homeassistant/components/airos/__init__.py +++ b/homeassistant/components/airos/__init__.py @@ -116,10 +116,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: """Migrate old config entry.""" - # This means the user has downgraded from a future version - if entry.version > 2: - return False - # 1.1 Migrate config_entry to add advanced ssl settings if entry.version == 1 and entry.minor_version == 1: new_minor_version = 2 diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py index b04be74029e6..894e18d82a74 100644 --- a/homeassistant/components/alexa_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -65,10 +65,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool: """Migrate old entry.""" - if entry.version > 1: - # This means the user has downgraded from a future version - return False - if entry.version == 1 and entry.minor_version < 3: if CONF_SITE in entry.data: # Site in data (wrong place), just move to login data diff --git a/homeassistant/components/anova/__init__.py b/homeassistant/components/anova/__init__.py index 8155807cb779..463c473db4d1 100644 --- a/homeassistant/components/anova/__init__.py +++ b/homeassistant/components/anova/__init__.py @@ -75,10 +75,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnovaConfigEntry) -> b """Migrate entry.""" _LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) - if entry.version > 1: - # This means the user has downgraded from a future version - return False - if entry.version == 1 and entry.minor_version == 1: new_data = {**entry.data} if CONF_DEVICES in new_data: diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index 7ddddecb428c..3d7c3ce41386 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -178,10 +178,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) """Migrate entry.""" LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) - if entry.version > 2: - # This means the user has downgraded from a future version - return False - if entry.version == 2 and entry.minor_version == 1: # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 device_registry = dr.async_get(hass) diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 8cfd065c916e..6655ec11033d 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -230,10 +230,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> entry.minor_version, ) - if entry.version > 1: - # Downgraded from a future version; cannot migrate. - return False - # 1.1 -> 1.2: Add CONF_HEATING_CIRCUITS. Attempt to discover available # heating circuits from the device; fall back to [1] (pre-multi-circuit # default) if the device is unreachable or the endpoint is unsupported. diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index 9e7b4ee09912..ab7a1d3bd509 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -87,10 +87,6 @@ async def async_migrate_entry( ) -> bool: """Migrate old entry.""" - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False - if config_entry.version == 1 and config_entry.minor_version == 1: device_registry = dr.async_get(hass) diff --git a/homeassistant/components/derivative/__init__.py b/homeassistant/components/derivative/__init__.py index a3bd88a38a2e..ce593e5f8f8c 100644 --- a/homeassistant/components/derivative/__init__.py +++ b/homeassistant/components/derivative/__init__.py @@ -54,10 +54,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry.minor_version, ) - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False - if config_entry.version == 1: if config_entry.minor_version < 2: new_options = {**config_entry.options} diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py index a6d94bcf6f20..0242ec2eec08 100644 --- a/homeassistant/components/dnsip/__init__.py +++ b/homeassistant/components/dnsip/__init__.py @@ -111,10 +111,6 @@ async def async_migrate_entry( ) -> bool: """Migrate old entry to a newer version.""" - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False - if config_entry.version < 2 and config_entry.minor_version < 2: _LOGGER.debug( "Migrating configuration from version %s.%s", diff --git a/homeassistant/components/elevenlabs/__init__.py b/homeassistant/components/elevenlabs/__init__.py index 76d128818071..ef480e8ed492 100644 --- a/homeassistant/components/elevenlabs/__init__.py +++ b/homeassistant/components/elevenlabs/__init__.py @@ -98,10 +98,6 @@ async def async_migrate_entry( config_entry.minor_version, ) - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False - if config_entry.version == 1: new_options = {**config_entry.options} diff --git a/homeassistant/components/epson/__init__.py b/homeassistant/components/epson/__init__.py index 5bb31566a463..075227273563 100644 --- a/homeassistant/components/epson/__init__.py +++ b/homeassistant/components/epson/__init__.py @@ -88,10 +88,6 @@ async def async_migrate_entry( config_entry.minor_version, ) - if config_entry.version > 1 or config_entry.minor_version > 1: - # This means the user has downgraded from a future version - return False - if config_entry.version == 1 and config_entry.minor_version == 1: new_data = {**config_entry.data} new_data[CONF_CONNECTION_TYPE] = HTTP diff --git a/homeassistant/components/file/__init__.py b/homeassistant/components/file/__init__.py index 53417cc25dd0..54b36b33c814 100644 --- a/homeassistant/components/file/__init__.py +++ b/homeassistant/components/file/__init__.py @@ -53,9 +53,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate config entry.""" - if config_entry.version > 2: - # Downgraded from future - return False if config_entry.version < 2: # Move optional fields from data to options in config entry diff --git a/homeassistant/components/fujitsu_fglair/__init__.py b/homeassistant/components/fujitsu_fglair/__init__.py index 4396739fbfa0..0a20e494be88 100644 --- a/homeassistant/components/fujitsu_fglair/__init__.py +++ b/homeassistant/components/fujitsu_fglair/__init__.py @@ -48,8 +48,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: FGLairConfigEntry) -> b async def async_migrate_entry(hass: HomeAssistant, entry: FGLairConfigEntry) -> bool: """Migrate old entry.""" - if entry.version > 1: - return False if entry.version == 1: new_data = {**entry.data} diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py index 1d519ed3b83d..5fce8af990c8 100644 --- a/homeassistant/components/fyta/__init__.py +++ b/homeassistant/components/fyta/__init__.py @@ -65,10 +65,6 @@ async def async_migrate_entry( """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", config_entry.version) - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False - if config_entry.version == 1: if config_entry.minor_version < 2: new = {**config_entry.data} diff --git a/homeassistant/components/generic/__init__.py b/homeassistant/components/generic/__init__.py index 29da4e7ac6c3..f50d445a82db 100644 --- a/homeassistant/components/generic/__init__.py +++ b/homeassistant/components/generic/__init__.py @@ -59,10 +59,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate entry.""" _LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) - if entry.version > 2: - # This means the user has downgraded from a future version - return False - if entry.version == 1: # Migrate to advanced section new_options = {**entry.options} diff --git a/homeassistant/components/generic_hygrostat/__init__.py b/homeassistant/components/generic_hygrostat/__init__.py index 624d0fb861b6..9af17b89c1ce 100644 --- a/homeassistant/components/generic_hygrostat/__init__.py +++ b/homeassistant/components/generic_hygrostat/__init__.py @@ -148,9 +148,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> "Migrating from version %s.%s", config_entry.version, config_entry.minor_version ) - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False if config_entry.version == 1: options = {**config_entry.options} if config_entry.minor_version < 2: diff --git a/homeassistant/components/generic_thermostat/__init__.py b/homeassistant/components/generic_thermostat/__init__.py index 991bc0d29035..e2e997b9c11b 100644 --- a/homeassistant/components/generic_thermostat/__init__.py +++ b/homeassistant/components/generic_thermostat/__init__.py @@ -76,9 +76,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> "Migrating from version %s.%s", config_entry.version, config_entry.minor_version ) - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False if config_entry.version == 1: options = {**config_entry.options} if config_entry.minor_version < 2: diff --git a/homeassistant/components/goodwe/__init__.py b/homeassistant/components/goodwe/__init__.py index abf95bd3a10e..8f8eda121397 100644 --- a/homeassistant/components/goodwe/__init__.py +++ b/homeassistant/components/goodwe/__init__.py @@ -98,10 +98,6 @@ async def async_migrate_entry( ) -> bool: """Migrate old config entries.""" - if config_entry.version > 2: - # This means the user has downgraded from a future version - return False - if config_entry.version == 1: # Update from version 1 to version 2 adding the PROTOCOL to the config entry host = config_entry.data[CONF_HOST] diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 407ede0c3281..b3f0eb0ce829 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -226,10 +226,6 @@ async def async_migrate_entry( """Migrate entry.""" LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) - if entry.version > 2: - # This means the user has downgraded from a future version - return False - if entry.version == 2 and entry.minor_version == 1: # Add TTS subentry which was missing in 2025.7.0b0 if not any( diff --git a/homeassistant/components/history_stats/__init__.py b/homeassistant/components/history_stats/__init__.py index 5af162fa29dc..1b95f09534e2 100644 --- a/homeassistant/components/history_stats/__init__.py +++ b/homeassistant/components/history_stats/__init__.py @@ -100,9 +100,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> "Migrating from version %s.%s", config_entry.version, config_entry.minor_version ) - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False if config_entry.version == 1: options = {**config_entry.options} if config_entry.minor_version < 2: diff --git a/homeassistant/components/homeassistant_connect_zbt2/__init__.py b/homeassistant/components/homeassistant_connect_zbt2/__init__.py index 166744eccc8b..64a022c1373f 100644 --- a/homeassistant/components/homeassistant_connect_zbt2/__init__.py +++ b/homeassistant/components/homeassistant_connect_zbt2/__init__.py @@ -108,10 +108,6 @@ async def async_migrate_entry( config_entry.minor_version, ) - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False - if config_entry.version == 1: if config_entry.minor_version == 1: serial_number = config_entry.data[SERIAL_NUMBER] diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 16e2ce377ed4..142673d7e5b7 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -140,10 +140,6 @@ async def async_migrate_entry( "Migrating from version %s.%s", config_entry.version, config_entry.minor_version ) - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False - if config_entry.version == 1: if config_entry.minor_version == 1: # Add-on startup with type service get started before diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 270d3e46af98..e18631c7049b 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -126,8 +126,6 @@ async def async_migrate_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry ) -> bool: """Migrate the config entry from version 1 to version 2.""" - if config_entry.version > 2: - return False if config_entry.version == 1: _LOGGER.debug("Migrating HomematicIP Cloud config entry to version 2") diff --git a/homeassistant/components/integration/__init__.py b/homeassistant/components/integration/__init__.py index 1a4fab2a6fa2..eb8650dc6490 100644 --- a/homeassistant/components/integration/__init__.py +++ b/homeassistant/components/integration/__init__.py @@ -49,9 +49,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> "Migrating from version %s.%s", config_entry.version, config_entry.minor_version ) - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False if config_entry.version == 1: options = {**config_entry.options} if config_entry.minor_version < 2: diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index 2450d7e0b161..d0731bb693d5 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -52,9 +52,6 @@ async def async_migrate_entry( """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", config_entry.version) - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False if config_entry.version == 1: new = {**config_entry.data} if config_entry.minor_version < 2: diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index d68de2059741..5430647e7a02 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -131,10 +131,6 @@ async def async_migrate_entry( return {"new_unique_id": new_unique_id} return None - if config_entry.version > 2: - # This means the user has downgraded from a future version - return False - if config_entry.version == 1: await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) hass.config_entries.async_update_entry(config_entry, version=2) diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index 10a8db33b126..b44228ad0f79 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -217,9 +217,6 @@ async def async_migrate_entry( hass: HomeAssistant, entry: LaMarzoccoConfigEntry ) -> bool: """Migrate config entry.""" - if entry.version > 4: - # guard against downgrade from a future version - return False if entry.version in (1, 2): _LOGGER.error( diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py index 350c5f4fc06e..aa1144166898 100644 --- a/homeassistant/components/litterrobot/__init__.py +++ b/homeassistant/components/litterrobot/__init__.py @@ -48,9 +48,6 @@ async def async_migrate_entry( entry.minor_version, ) - if entry.version > 1: - return False - if entry.minor_version < 2: account = Account(websession=async_get_clientsession(hass)) try: diff --git a/homeassistant/components/mold_indicator/__init__.py b/homeassistant/components/mold_indicator/__init__.py index 5150927e4c00..d60b5f0c696d 100644 --- a/homeassistant/components/mold_indicator/__init__.py +++ b/homeassistant/components/mold_indicator/__init__.py @@ -99,10 +99,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry.minor_version, ) - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False - if config_entry.version == 1: if config_entry.minor_version < 2: # Remove the mold indicator config entry from the source device diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 47c6cf27bc24..619e32700772 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -467,10 +467,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version) data: dict[str, Any] = dict(entry.data) options: dict[str, Any] = dict(entry.options) - if entry.version > 2 or (entry.version == 2 and entry.minor_version > 1): - # This means the user has downgraded from a future version - # We allow read support for version 2.1 - return False if entry.version == 1 and entry.minor_version < 2: # Can be removed when the config entry is bumped to version 2.1 diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index 666fd3d10124..24feb44320b5 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -44,9 +44,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bo minor_version = entry.minor_version _LOGGER.debug("Migrating from version %s.%s", version, minor_version) - if entry.version > 1: - # This means the user has downgraded from a future version - return False new_data: dict[str, Any] = {**entry.data, CONF_FILTERS: {}} diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index b10c854971c4..ad2d7aa3705b 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -116,10 +116,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "Migrating configuration from version %s.%s", entry.version, entry.minor_version ) - if entry.version > 1: - # This means the user has downgraded from a future version - return False - if entry.version == 1: new_options = {**entry.options} if entry.minor_version < 2: diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index b0ad4d433414..3f2bbcba4a0b 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -236,10 +236,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> """Migrate entry.""" _LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) - if entry.version > 3: - # This means the user has downgraded from a future version - return False - if entry.version == 2 and entry.minor_version == 1: # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 device_registry = dr.async_get(hass) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 35ccf3d50836..01f048fb2f8d 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -133,9 +133,6 @@ async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) - async def async_migrate_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: """Migrate old entry.""" - if entry.version > 1: - # This means the user has downgraded from a future version - return False if (version := entry.version) == 1 and (minor_version := entry.minor_version) == 1: _LOGGER.debug( diff --git a/homeassistant/components/open_router/__init__.py b/homeassistant/components/open_router/__init__.py index a28a082ce0ff..cc82a1648fa4 100644 --- a/homeassistant/components/open_router/__init__.py +++ b/homeassistant/components/open_router/__init__.py @@ -63,9 +63,6 @@ async def async_migrate_entry( """Migrate config entry.""" LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version) - if entry.version > 1 or (entry.version == 1 and entry.minor_version > 2): - return False - if entry.version == 1 and entry.minor_version < 2: for subentry in entry.subentries.values(): if CONF_WEB_SEARCH in subentry.data: diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 97805bd82aa6..f34f88c2cae7 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -428,10 +428,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> """Migrate entry.""" LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) - if entry.version > 2: - # This means the user has downgraded from a future version - return False - if entry.version == 2 and entry.minor_version == 1: # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 device_registry = dr.async_get(hass) diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index d32fae005e73..638c1183543b 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -201,8 +201,6 @@ async def async_migrate_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry ) -> bool: """Migrate old entry.""" - if entry.version > 1: - return False if entry.version == 1 and entry.minor_version < 2: await _async_migrate_strenum_unique_ids(hass, entry) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 3f90360968cb..21a80d6f97cf 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -253,9 +253,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: RoborockConfigEntry) - entry.version, entry.minor_version, ) - if entry.version > 1: - # Downgrade from future version - return False # 1->2: Migrate from unique id as email address to unique id as rruid if entry.minor_version == 1: diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py index e328372f242c..41381ea24836 100644 --- a/homeassistant/components/russound_rio/__init__.py +++ b/homeassistant/components/russound_rio/__init__.py @@ -115,9 +115,6 @@ async def async_migrate_entry( hass: HomeAssistant, config_entry: RussoundConfigEntry ) -> bool: """Migrate old entry.""" - if config_entry.version > 2: - # This means the user has downgraded from a future version - return False if config_entry.version == 1: ( diff --git a/homeassistant/components/satel_integra/__init__.py b/homeassistant/components/satel_integra/__init__.py index dc07df405dac..69ed6339f29e 100644 --- a/homeassistant/components/satel_integra/__init__.py +++ b/homeassistant/components/satel_integra/__init__.py @@ -121,10 +121,6 @@ async def async_migrate_entry( config_entry.minor_version, ) - if config_entry.version > 2: - # This means the user has downgraded from a future version - return False - # 1.2 Migrate subentries to include configured numbers to title if config_entry.version == 1 and config_entry.minor_version == 1: for subentry in config_entry.subentries.values(): diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index 740b80a54e33..97b149b54b46 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -153,10 +153,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bo async def async_migrate_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bool: """Migrate old entry.""" - if entry.version > 2: - # Don't migrate from future version - return False - if entry.version == 1: old_to_new_sensor_id = {} for sensor_config in entry.options[SENSOR_DOMAIN]: diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index c46e89fc55bd..73972330e6e2 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -134,8 +134,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_migrate_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> bool: """Migrate old config entries.""" - if entry.version > 1 or (entry.version == 1 and entry.minor_version > 3): - return False + if entry.minor_version < 3: # One-time flip of explicit Active scanning to Auto so existing # installs get the new battery-friendly default; Passive stays diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py index a3cdd5d9a8f8..92eb957c627c 100644 --- a/homeassistant/components/smhi/__init__.py +++ b/homeassistant/components/smhi/__init__.py @@ -47,10 +47,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: SMHIConfigEntry) -> boo async def async_migrate_entry(hass: HomeAssistant, entry: SMHIConfigEntry) -> bool: """Migrate old entry.""" - if entry.version > 3: - # Downgrade from future version - return False - if entry.version == 1: new_data = { CONF_NAME: entry.data[CONF_NAME], diff --git a/homeassistant/components/solarlog/__init__.py b/homeassistant/components/solarlog/__init__.py index b1858f7d0696..f3f971360f9f 100644 --- a/homeassistant/components/solarlog/__init__.py +++ b/homeassistant/components/solarlog/__init__.py @@ -93,10 +93,6 @@ async def async_migrate_entry( """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", config_entry.version) - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False - if config_entry.version == 1: if config_entry.minor_version < 2: # migrate old entity unique id diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index c0e0498297ba..0c644e6d5c7f 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -108,10 +108,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old entry.""" _LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version) - if entry.version > 1: - # This means the user has downgraded from a future version - return False - if entry.version == 1: old_options = {**entry.options} new_data = {} diff --git a/homeassistant/components/statistics/__init__.py b/homeassistant/components/statistics/__init__.py index 2e1c2126cb4a..49dcb19ceb56 100644 --- a/homeassistant/components/statistics/__init__.py +++ b/homeassistant/components/statistics/__init__.py @@ -57,9 +57,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> "Migrating from version %s.%s", config_entry.version, config_entry.minor_version ) - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False if config_entry.version == 1: options = {**config_entry.options} if config_entry.minor_version < 2: diff --git a/homeassistant/components/suez_water/__init__.py b/homeassistant/components/suez_water/__init__.py index 2104b49fd70d..5ff9c3bca7ce 100644 --- a/homeassistant/components/suez_water/__init__.py +++ b/homeassistant/components/suez_water/__init__.py @@ -41,9 +41,6 @@ async def async_migrate_entry( config_entry.minor_version, ) - if config_entry.version > 2: - return False - if config_entry.version == 1: # Migrate to version 2 counter_id = config_entry.data.get(CONF_COUNTER_ID) diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index d564947d22b3..67706d6f8599 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -120,10 +120,6 @@ async def async_migrate_entry( """Migrate config entry.""" _LOGGER.debug("Migrating from version %s", config_entry.version) - if config_entry.version > 3: - # This means the user has downgraded from a future version - return False - if config_entry.version == 1 and config_entry.minor_version == 1: # Remove wrongly registered devices and entries new_unique_id = unique_id_from_config(config_entry.data) diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index 93de0befa187..ef0a5cc5e3a0 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -81,9 +81,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> "Migrating from version %s.%s", config_entry.version, config_entry.minor_version ) - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False if config_entry.version == 1: options = {**config_entry.options} if config_entry.minor_version < 2: diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index 724498ab9a61..c2bf39f51732 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -28,7 +28,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -from .config_flow import SystemBridgeConfigFlow from .const import DATA_WAIT_TIMEOUT, DOMAIN, MODULES from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator from .services import async_setup_services @@ -215,9 +214,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry.minor_version, ) - if config_entry.version > SystemBridgeConfigFlow.VERSION: - return False - if config_entry.minor_version < 2: # Migrate to CONF_TOKEN, which was added in 1.2 new_data = dict(config_entry.data) diff --git a/homeassistant/components/systemmonitor/__init__.py b/homeassistant/components/systemmonitor/__init__.py index 25027048c72e..e26a69f1d059 100644 --- a/homeassistant/components/systemmonitor/__init__.py +++ b/homeassistant/components/systemmonitor/__init__.py @@ -74,10 +74,6 @@ async def async_migrate_entry( ) -> bool: """Migrate old entry.""" - if entry.version > 1: - # This means the user has downgraded from a future version - return False - if entry.version == 1 and entry.minor_version < 3: new_options = {**entry.options} if entry.minor_version == 1: diff --git a/homeassistant/components/tedee/__init__.py b/homeassistant/components/tedee/__init__.py index 38035f1dd11e..43cacb4cd652 100644 --- a/homeassistant/components/tedee/__init__.py +++ b/homeassistant/components/tedee/__init__.py @@ -134,9 +134,6 @@ async def async_migrate_entry( hass: HomeAssistant, config_entry: TedeeConfigEntry ) -> bool: """Migrate old entry.""" - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False version = config_entry.version minor_version = config_entry.minor_version diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 2fdd009bcd2d..18c979b2f373 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -691,10 +691,6 @@ async def async_migrate_entry( minor_version, ) - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False - # version 1.1: to add default API endpoint if version == 1 and minor_version == 1: new_data = {**config_entry.data} diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 529560569d87..0907f6783904 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -163,10 +163,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry.minor_version, ) - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False - if config_entry.version == 1: if config_entry.minor_version < 2: # Remove the template config entry from the source device diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index eb99d2bb2bd1..0949be013258 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -453,9 +453,6 @@ async def async_migrate_entry( hass: HomeAssistant, config_entry: TeslemetryConfigEntry ) -> bool: """Migrate config entry.""" - if config_entry.version > 2: - # This means the user has downgraded from a future version - return False if config_entry.version == 1: access_token = config_entry.data[CONF_ACCESS_TOKEN] diff --git a/homeassistant/components/threshold/__init__.py b/homeassistant/components/threshold/__init__.py index 161acd0aa5bf..695d73859603 100644 --- a/homeassistant/components/threshold/__init__.py +++ b/homeassistant/components/threshold/__init__.py @@ -50,9 +50,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> "Migrating from version %s.%s", config_entry.version, config_entry.minor_version ) - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False if config_entry.version == 1: options = {**config_entry.options} if config_entry.minor_version < 2: diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index 018a0f3d375e..55c6c5ced34c 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -182,10 +182,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry.minor_version, ) - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False - if config_entry.version == 1: # Migrate to version 2 migrate_config_entry_and_identifiers(hass, config_entry) diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py index 785b20760430..6afd125fd259 100644 --- a/homeassistant/components/trafikverket_train/__init__.py +++ b/homeassistant/components/trafikverket_train/__init__.py @@ -54,10 +54,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> """Migrate config entry.""" _LOGGER.debug("Migrating from version %s", entry.version) - if entry.version > 2: - # This means the user has downgraded from a future version - return False - if entry.version == 1: if entry.minor_version == 1: # Remove unique id diff --git a/homeassistant/components/trend/__init__.py b/homeassistant/components/trend/__init__.py index a7c4d7be0893..c5a8549e91c0 100644 --- a/homeassistant/components/trend/__init__.py +++ b/homeassistant/components/trend/__init__.py @@ -56,9 +56,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> "Migrating from version %s.%s", config_entry.version, config_entry.minor_version ) - if config_entry.version > 1: - # This means the user has downgraded from a future version - return False if config_entry.version == 1: options = {**config_entry.options} if config_entry.minor_version < 2: diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 5d1886dc68bd..4d6b24e1768c 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -238,9 +238,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> boo """Migrate entry.""" _LOGGER.debug("Migrating configuration from version %s", entry.version) - if entry.version > 1: - return False - if entry.version == 1: options = dict(entry.options) if CONF_ALLOW_EA in options: diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index 6eebea8e6ba1..a0e2c77341c6 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -253,10 +253,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> "Migrating from version %s.%s", config_entry.version, config_entry.minor_version ) - if config_entry.version > 2: - # This means the user has downgraded from a future version - return False - if config_entry.version == 1: new = {**config_entry.options} new[CONF_METER_PERIODICALLY_RESETTING] = True diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index d72ae600d158..2df6790ae1c8 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -55,8 +55,6 @@ async def async_migrate_entry( hass: HomeAssistant, config_entry: ViCareConfigEntry ) -> bool: """Migrate old entry.""" - if config_entry.version > 2: - return False if config_entry.version == 1 and config_entry.minor_version < 2: _LOGGER.debug("Migrating ViCare config entry from version 1.1 to 1.2") diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index 3c8305e7fdef..317ba5d08aaf 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -38,10 +38,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> async def async_migrate_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> bool: """Migrate old entry.""" - if entry.version > 1: - # This means the user has downgraded from a future version - return False - if entry.version == 1 and entry.minor_version == 1: _LOGGER.debug( "Migrating from version %s.%s", entry.version, entry.minor_version diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index 35868c072a30..a592720cd64d 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -81,10 +81,6 @@ async def async_migrate_entry( config_entry.minor_version, ) - if config_entry.version > 1: - # The user has downgraded from a future version - return False - if config_entry.version == 1: if config_entry.minor_version < 2: # 1.2: Normalize unique ID to be lowercase MAC address without separators. diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index 4f583425980b..29dc0094b42e 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -74,10 +74,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: WorkdayConfigEntry) -> async def async_migrate_entry(hass: HomeAssistant, entry: WorkdayConfigEntry) -> bool: """Migrate old config entry.""" - # This means the user has downgraded from a future version - if entry.version > 1: - return False - if entry.version == 1 and entry.minor_version == 1: # By keeping name in the data, it's enough to bump the minor version hass.config_entries.async_update_entry( diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 99f745e4248b..388d22664ea2 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -33,7 +33,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType from . import homeassistant_hardware, repairs, websocket_api -from .config_flow import ZhaConfigFlowHandler from .const import ( CONF_BAUDRATE, CONF_CUSTOM_QUIRKS_PATH, @@ -310,10 +309,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry.minor_version, ) - if config_entry.version > ZhaConfigFlowHandler.VERSION: - # This means the user has downgraded from a future major version - return False - if config_entry.version == 1: data = { CONF_RADIO_TYPE: config_entry.data[CONF_RADIO_TYPE], diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index c2d1e9c8f9a5..dce672033c0c 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -2631,7 +2631,6 @@ async def test_migrate_config_entry( "options", ), [ - (2, 2, MOCK_ENTRY_DATA, MOCK_ENTRY_OPTIONS), (3, 1, MOCK_ENTRY_DATA, MOCK_ENTRY_OPTIONS), ], ) diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index a2c567299fdd..e5c49113173c 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -663,16 +663,9 @@ async def test_migrate_ble_scanner_mode( assert entry.options.get(CONF_BLE_SCANNER_MODE) == expected_mode -@pytest.mark.parametrize( - ("entry_version", "entry_minor_version"), - [(2, 1), (1, 4)], - ids=["future_major", "future_minor"], -) async def test_migrate_ble_scanner_mode_future_version( hass: HomeAssistant, mock_rpc_device: Mock, - entry_version: int, - entry_minor_version: int, ) -> None: """Future versions are not downgraded.""" entry = MockConfigEntry( @@ -686,16 +679,45 @@ async def test_migrate_ble_scanner_mode_future_version( unique_id=MOCK_MAC, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE}, title="Test name", - version=entry_version, - minor_version=entry_minor_version, + version=2, + minor_version=1, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) assert entry.state is ConfigEntryState.MIGRATION_ERROR - assert entry.version == entry_version - assert entry.minor_version == entry_minor_version + assert entry.version == 2 + assert entry.minor_version == 1 + assert entry.options[CONF_BLE_SCANNER_MODE] == BLEScannerMode.ACTIVE + + +async def test_migrate_ble_scanner_mode_future_minor_version( + hass: HomeAssistant, + mock_rpc_device: Mock, +) -> None: + """Future minor versions are not downgraded.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.37", + CONF_SLEEP_PERIOD: 0, + CONF_MODEL: MODEL_PLUS_2PM, + "gen": 2, + }, + unique_id=MOCK_MAC, + options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE}, + title="Test name", + version=1, + minor_version=4, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 1 + assert entry.minor_version == 4 assert entry.options[CONF_BLE_SCANNER_MODE] == BLEScannerMode.ACTIVE From 9b5038d7410f1bb33a425c8f98ffeb9e11af429a Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 8 Jun 2026 21:08:26 -0700 Subject: [PATCH 026/404] Bump opower to 0.18.4 (#173323) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index d45e8f6e7212..2d1174bccba0 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["opower"], "quality_scale": "platinum", - "requirements": ["opower==0.18.3"] + "requirements": ["opower==0.18.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9cb747998d9a..d29f40695f19 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1779,7 +1779,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.18.3 +opower==0.18.4 # homeassistant.components.oralb oralb-ble==1.1.0 From 2b5fc802c46715914167b2e421e68fa60874da22 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 06:14:11 +0200 Subject: [PATCH 027/404] Update rf-protocols to 4.1.0 (#173328) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- homeassistant/components/radio_frequency/manifest.json | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/radio_frequency/manifest.json b/homeassistant/components/radio_frequency/manifest.json index 049b7ddfc4fc..959f7ba8bb2a 100644 --- a/homeassistant/components/radio_frequency/manifest.json +++ b/homeassistant/components/radio_frequency/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/radio_frequency", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["rf-protocols==4.0.1"] + "requirements": ["rf-protocols==4.1.0"] } diff --git a/requirements.txt b/requirements.txt index 3b42bc85e50a..f75633bb8bbd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,7 +47,7 @@ python-slugify==8.0.4 PyTurboJPEG==1.8.3 PyYAML==6.0.3 requests==2.34.2 -rf-protocols==4.0.1 +rf-protocols==4.1.0 securetar==2026.4.1 SQLAlchemy==2.0.50 standard-aifc==3.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index d29f40695f19..c017f3ec71d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2899,7 +2899,7 @@ renson-endura-delta==1.7.2 reolink-aio==0.20.1 # homeassistant.components.radio_frequency -rf-protocols==4.0.1 +rf-protocols==4.1.0 # homeassistant.components.idteck_prox rfk101py==0.0.1 From 05f4e28c86c79c7271ffa26292515b997ae70c85 Mon Sep 17 00:00:00 2001 From: Hai-Nam Nguyen Date: Tue, 9 Jun 2026 06:19:10 +0200 Subject: [PATCH 028/404] Bump hyponcloud to 1.0.0 (#173310) --- homeassistant/components/hypontech/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hypontech/manifest.json b/homeassistant/components/hypontech/manifest.json index 54cefce54764..0ba59fb8be9d 100644 --- a/homeassistant/components/hypontech/manifest.json +++ b/homeassistant/components/hypontech/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["hyponcloud==0.9.3"] + "requirements": ["hyponcloud==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c017f3ec71d5..6653fb4de91c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1302,7 +1302,7 @@ huum==0.8.2 hyperion-py==0.7.6 # homeassistant.components.hypontech -hyponcloud==0.9.3 +hyponcloud==1.0.0 # homeassistant.components.iammeter iammeter==0.2.1 From 49d1e0ea5bafcded884644f13f589abe58e3044b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Tue, 9 Jun 2026 06:20:41 +0200 Subject: [PATCH 029/404] Bump pyaqvify to 0.0.9 (#173312) --- homeassistant/components/aqvify/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/aqvify/manifest.json b/homeassistant/components/aqvify/manifest.json index fadc9f9e1e82..0fe36b3b6031 100644 --- a/homeassistant/components/aqvify/manifest.json +++ b/homeassistant/components/aqvify/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["pyaqvify"], "quality_scale": "bronze", - "requirements": ["pyaqvify==0.0.8"] + "requirements": ["pyaqvify==0.0.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6653fb4de91c..9bb3f98df7ae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2018,7 +2018,7 @@ pyanglianwater==3.1.2 pyaprilaire==0.9.1 # homeassistant.components.aqvify -pyaqvify==0.0.8 +pyaqvify==0.0.9 # homeassistant.components.atag pyatag==0.3.5.3 From 1cfae60c03a2ca1eff217df163a3831fa5b6ef2a Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 9 Jun 2026 06:23:58 +0200 Subject: [PATCH 030/404] Bump uiprotect to 12.0.0 (#173315) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 1cbab8c2f23a..3ab30a381f97 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==11.8.0"] + "requirements": ["uiprotect==12.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9bb3f98df7ae..5dad0a26391d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3249,7 +3249,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==11.8.0 +uiprotect==12.0.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.6.1 From b1610163f1dcd7eb9b2260cbd24ef3ec93ce195e Mon Sep 17 00:00:00 2001 From: Marcello <58506324+Marcello17@users.noreply.github.com> Date: Tue, 9 Jun 2026 08:35:09 +0200 Subject: [PATCH 031/404] Set PARALLEL_UPDATES for Fluss platforms (#173286) --- homeassistant/components/fluss/button.py | 2 ++ homeassistant/components/fluss/cover.py | 2 +- homeassistant/components/fluss/quality_scale.yaml | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fluss/button.py b/homeassistant/components/fluss/button.py index ab238396eb75..fdc43b81ddb8 100644 --- a/homeassistant/components/fluss/button.py +++ b/homeassistant/components/fluss/button.py @@ -8,6 +8,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import FlussApiClientError, FlussConfigEntry from .entity import FlussEntity +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/fluss/cover.py b/homeassistant/components/fluss/cover.py index 541dc48ada5e..c4eef7c9e632 100644 --- a/homeassistant/components/fluss/cover.py +++ b/homeassistant/components/fluss/cover.py @@ -15,7 +15,7 @@ from .const import DOMAIN from .coordinator import FlussApiClientError, FlussConfigEntry from .entity import FlussEntity -PARALLEL_UPDATES = 0 +PARALLEL_UPDATES = 1 STATUS_OPEN = "Open" STATUS_CLOSED = "Closed" diff --git a/homeassistant/components/fluss/quality_scale.yaml b/homeassistant/components/fluss/quality_scale.yaml index c2b4a85a6887..0009c1a7a958 100644 --- a/homeassistant/components/fluss/quality_scale.yaml +++ b/homeassistant/components/fluss/quality_scale.yaml @@ -28,7 +28,7 @@ rules: docs-installation-parameters: done integration-owner: done log-when-unavailable: done - parallel-updates: todo + parallel-updates: done reauthentication-flow: todo test-coverage: todo # Gold From 2287c92f8854ee1777edc6ca2c7d21f6a0786c7b Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Tue, 9 Jun 2026 08:44:07 +0200 Subject: [PATCH 032/404] Bump zwave-js-server-python to 0.72.0 (#173309) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 035d9a6f4a5c..9b2529ca58f0 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["zwave-js-server-python==0.71.0"], + "requirements": ["zwave-js-server-python==0.72.0"], "usb": [ { "known_devices": ["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"], diff --git a/requirements_all.txt b/requirements_all.txt index 5dad0a26391d..17ccc5d418dd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3475,7 +3475,7 @@ zinvolt==0.4.3 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.71.0 +zwave-js-server-python==0.72.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From 5d3f7001ab9fa280171c4a08c5179d9d312c6c3d Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 9 Jun 2026 08:46:02 +0200 Subject: [PATCH 033/404] Clean up redundant URL parsing in the Overkiz (#173273) --- .../climate/atlantic_pass_apc_heating_zone.py | 6 ++++-- homeassistant/components/overkiz/entity.py | 21 ++++++------------- homeassistant/components/overkiz/executor.py | 14 +++---------- homeassistant/components/overkiz/sensor.py | 2 +- 4 files changed, 14 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py b/homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py index a8f64d3caecd..177726142661 100644 --- a/homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py +++ b/homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py @@ -98,8 +98,10 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity): super().__init__(device_url, coordinator) # Temperature sensor use the same base_device_url and use the n+1 index - self.temperature_device = self.executor.linked_device( - int(self.index_device_url) + 1 + self.temperature_device = ( + self.executor.linked_device(subsystem_id + 1) + if (subsystem_id := self.device.identifier.subsystem_id) is not None + else None ) @property diff --git a/homeassistant/components/overkiz/entity.py b/homeassistant/components/overkiz/entity.py index a637fde4ade3..8eb29250b8ca 100644 --- a/homeassistant/components/overkiz/entity.py +++ b/homeassistant/components/overkiz/entity.py @@ -26,16 +26,12 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]): """Initialize the device.""" super().__init__(coordinator) self.device_url = device_url - split_device_url = self.device_url.split("#") - self.base_device_url = split_device_url[0] - if len(split_device_url) == 2: - self.index_device_url = split_device_url[1] self.executor = OverkizExecutor(device_url, coordinator) self._attr_assumed_state = not self.device.states self._attr_unique_id = self.device.device_url - if self.is_sub_device: + if self.device.identifier.is_sub_device: # In case of sub entity, use the provided label as name self._attr_name = self.device.label @@ -60,11 +56,6 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]): return False - @property - def is_sub_device(self) -> bool: - """Return True if device is a sub device.""" - return "#" in self.device_url and not self.device_url.endswith("#1") - @property def device(self) -> Device: """Return Overkiz device linked to this entity.""" @@ -75,11 +66,11 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]): # Some devices, such as the Smart Thermostat have several devices # in one physical device, with same device url, terminated by '#' and a number. # In this case, we use the base device url as the device identifier. - if self.is_sub_device: + if self.device.identifier.is_sub_device: # Only return the url of the base device, to inherit device name # and model from parent device. return DeviceInfo( - identifiers={(DOMAIN, self.executor.base_device_url)}, + identifiers={(DOMAIN, self.device.identifier.base_device_url)}, ) manufacturer = ( @@ -104,7 +95,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]): ) return DeviceInfo( - identifiers={(DOMAIN, self.executor.base_device_url)}, + identifiers={(DOMAIN, self.device.identifier.base_device_url)}, name=self.device.label, manufacturer=str(manufacturer), model=str(model), @@ -115,7 +106,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]): model_id=self.device.widget, hw_version=self.device.controllable_name, suggested_area=suggested_area, - via_device=(DOMAIN, self.executor.get_gateway_id()), + via_device=(DOMAIN, self.device.identifier.gateway_id), configuration_url=self.coordinator.client.server_config.configuration_url, ) @@ -134,7 +125,7 @@ class OverkizDescriptiveEntity(OverkizEntity): self.entity_description = description self._attr_unique_id = f"{super().unique_id}-{self.entity_description.key}" - if self.is_sub_device: + if self.device.identifier.is_sub_device: # In case of sub device, use the provided label # and append the name of the type of entity self._attr_name = f"{self.device.label} {description.name}" diff --git a/homeassistant/components/overkiz/executor.py b/homeassistant/components/overkiz/executor.py index e6aaa2706093..006b0f8877ce 100644 --- a/homeassistant/components/overkiz/executor.py +++ b/homeassistant/components/overkiz/executor.py @@ -1,7 +1,6 @@ """Class for helpers and communication with the OverKiz API.""" from typing import Any -from urllib.parse import urlparse from pyoverkiz.enums import OverkizCommand, Protocol from pyoverkiz.exceptions import BaseOverkizError @@ -34,7 +33,6 @@ class OverkizExecutor: """Initialize the executor.""" self.device_url = device_url self.coordinator = coordinator - self.base_device_url = self.device_url.split("#")[0] @property def device(self) -> Device: @@ -43,7 +41,9 @@ class OverkizExecutor: def linked_device(self, index: int) -> Device | None: """Return Overkiz device sharing the same base url.""" - return self.coordinator.data.get(f"{self.base_device_url}#{index}") + return self.coordinator.data.get( + f"{self.device.identifier.base_device_url}#{index}" + ) def select_command(self, *commands: str) -> str | None: """Select first existing command in a list of commands.""" @@ -169,11 +169,3 @@ class OverkizExecutor: async def async_cancel_execution(self, exec_id: str) -> None: """Cancel running execution via execution id.""" await self.coordinator.client.cancel_execution(exec_id) - - def get_gateway_id(self) -> str: - """Retrieve gateway id from device url. - - device URL (:///[#]) - """ - url = urlparse(self.device_url) - return url.netloc diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index c1c0a91dd7b5..c5c16821f41e 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -652,5 +652,5 @@ class OverkizHomeKitSetupCodeSensor(OverkizEntity, SensorEntity): # but it makes more sense to show this at the gateway device # in the entity registry. return DeviceInfo( - identifiers={(DOMAIN, self.executor.get_gateway_id())}, + identifiers={(DOMAIN, self.device.identifier.gateway_id)}, ) From d815487f7ffe160f5a9fafb28c71d00b06707ac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Tue, 9 Jun 2026 09:06:07 +0200 Subject: [PATCH 034/404] Add diagnostics platform to aqvify (#173283) --- .../components/aqvify/diagnostics.py | 30 +++++++++++++++++ .../aqvify/snapshots/test_diagnostics.ambr | 32 +++++++++++++++++++ tests/components/aqvify/test_diagnostics.py | 31 ++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 homeassistant/components/aqvify/diagnostics.py create mode 100644 tests/components/aqvify/snapshots/test_diagnostics.ambr create mode 100644 tests/components/aqvify/test_diagnostics.py diff --git a/homeassistant/components/aqvify/diagnostics.py b/homeassistant/components/aqvify/diagnostics.py new file mode 100644 index 000000000000..f1f49944fa0c --- /dev/null +++ b/homeassistant/components/aqvify/diagnostics.py @@ -0,0 +1,30 @@ +"""Diagnostics platform for Aqvify integration.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from .coordinator import AqvifyConfigEntry + +TO_REDACT = [CONF_API_KEY] +TO_REDACT_AQVIFY = ["name"] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: AqvifyConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + device_list_raw_data = entry.runtime_data.data.devices.raw + device_data_raw_data = { + key: device.raw_data + for key, device in entry.runtime_data.data.device_data.items() + } + + return { + "entry_data": async_redact_data(entry.data, TO_REDACT), + "devices": async_redact_data(device_list_raw_data, TO_REDACT_AQVIFY), + "device_data": device_data_raw_data, + } diff --git a/tests/components/aqvify/snapshots/test_diagnostics.ambr b/tests/components/aqvify/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..c24a1424a21a --- /dev/null +++ b/tests/components/aqvify/snapshots/test_diagnostics.ambr @@ -0,0 +1,32 @@ +# serializer version: 1 +# name: test_diagnostics_config_entry + dict({ + 'device_data': dict({ + 'DeviceKey_1': dict({ + 'dateTime': '2026-06-04T09:36:06+00:00', + 'meterValue': 0.823213995, + 'status': None, + 'waterLevel': -0.136786005, + }), + 'DeviceKey_2': dict({ + 'dateTime': '2026-06-04T09:36:06+00:00', + 'meterValue': 0.823213995, + 'status': None, + 'waterLevel': -0.136786005, + }), + }), + 'devices': list([ + dict({ + 'deviceKey': 'DeviceKey_1', + 'name': '**REDACTED**', + }), + dict({ + 'deviceKey': 'DeviceKey_2', + 'name': '**REDACTED**', + }), + ]), + 'entry_data': dict({ + 'api_key': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/aqvify/test_diagnostics.py b/tests/components/aqvify/test_diagnostics.py new file mode 100644 index 000000000000..2d0e1c957c56 --- /dev/null +++ b/tests/components/aqvify/test_diagnostics.py @@ -0,0 +1,31 @@ +"""Tests for the diagnostics data provided by the Aqvify integration.""" + +from collections.abc import Generator +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics_config_entry( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_aqvify_client: Generator[MagicMock], + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry.""" + + await setup_integration(hass, mock_config_entry) + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot From 33753460ab08014fe9b645bd2466952b7c451d5f Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 9 Jun 2026 08:59:12 +0100 Subject: [PATCH 035/404] Improve and complete exception handling for Alexa Devices (#173053) --- .../components/alexa_devices/button.py | 5 +- .../components/alexa_devices/coordinator.py | 113 ++++++++++++------ .../components/alexa_devices/media_player.py | 19 ++- .../components/alexa_devices/notify.py | 10 +- .../components/alexa_devices/services.py | 23 ++-- .../components/alexa_devices/switch.py | 12 +- .../components/alexa_devices/utils.py | 35 ------ .../alexa_devices/test_coordinator.py | 42 +++++++ tests/components/alexa_devices/test_utils.py | 34 +++++- 9 files changed, 180 insertions(+), 113 deletions(-) diff --git a/homeassistant/components/alexa_devices/button.py b/homeassistant/components/alexa_devices/button.py index 1eebe201c821..8a30f8337836 100644 --- a/homeassistant/components/alexa_devices/button.py +++ b/homeassistant/components/alexa_devices/button.py @@ -6,7 +6,7 @@ from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import slugify -from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator +from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator, alexa_api_call from .entity import AmazonServiceEntity # Coordinator is used to centralize the data updates @@ -49,4 +49,5 @@ class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity): async def async_press(self) -> None: """Handle button press action.""" - await self.coordinator.api.call_routine(self._routine) + async with alexa_api_call(self.coordinator): + await self.coordinator.api.call_routine(self._routine) diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index 4a67f5758172..98d2506a4ddd 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -1,5 +1,7 @@ """Support for Alexa Devices.""" +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager from datetime import timedelta from aioamazondevices.api import AmazonEchoApi @@ -19,7 +21,11 @@ from aiohttp import ClientSession from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -29,6 +35,65 @@ from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN SCAN_INTERVAL = 300 + +@asynccontextmanager +async def alexa_api_call( + coordinator: DataUpdateCoordinator | None = None, +) -> AsyncGenerator[None]: + """Handle common Alexa API exceptions as HomeAssistantError.""" + try: + yield + except CannotAuthenticate as err: + if coordinator: + coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"error": repr(err)}, + ) from err + except CannotConnect as err: + if coordinator: + coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect_with_error", + translation_placeholders={"error": repr(err)}, + ) from err + except (CannotRetrieveData, ValueError) as err: + if coordinator: + coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_retrieve_data_with_error", + translation_placeholders={"error": repr(err)}, + ) from err + + +@asynccontextmanager +async def alexa_config_entry_errors() -> AsyncGenerator[None]: + """Handle common Alexa API exceptions as ConfigEntry errors.""" + try: + yield + except CannotAuthenticate as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"error": repr(err)}, + ) from err + except (CannotConnect, TimeoutError) as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect_with_error", + translation_placeholders={"error": repr(err)}, + ) from err + except (CannotRetrieveData, ValueError, KeyError, StopIteration) as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_retrieve_data_with_error", + translation_placeholders={"error": repr(err)}, + ) from err + + type AmazonConfigEntry = ConfigEntry[AmazonDevicesCoordinator] @@ -113,6 +178,12 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): translation_key="invalid_auth", translation_placeholders={"error": repr(err)}, ) from err + except ValueError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_retrieve_data_with_error", + translation_placeholders={"error": repr(err)}, + ) from err else: current_devices = set(data.keys()) if stale_devices := self.previous_devices - current_devices: @@ -169,26 +240,8 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): async def sync_history_state(self) -> None: """Sync history state.""" - try: + async with alexa_config_entry_errors(): self._vocal_records = await self.api.sync_history_state() - except CannotAuthenticate as e: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="invalid_auth", - translation_placeholders={"error": repr(e)}, - ) from e - except CannotConnect as e: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="cannot_connect_with_error", - translation_placeholders={"error": repr(e)}, - ) from e - except BaseException as e: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="cannot_retrieve_data_with_error", - translation_placeholders={"error": repr(e)}, - ) from e async def history_state_event_handler( self, vocal_records: dict[str, AmazonVocalRecord] @@ -204,26 +257,8 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): async def sync_media_state(self) -> None: """Sync media state.""" - try: + async with alexa_config_entry_errors(): await self.api.sync_media_state() - except CannotAuthenticate as err: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="invalid_auth", - translation_placeholders={"error": repr(err)}, - ) from err - except (CannotConnect, TimeoutError) as err: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="cannot_connect_with_error", - translation_placeholders={"error": repr(err)}, - ) from err - except (CannotRetrieveData, ValueError) as err: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="cannot_retrieve_data_with_error", - translation_placeholders={"error": repr(err)}, - ) from err async def media_state_event_handler( self, media_state: dict[str, AmazonMediaState] diff --git a/homeassistant/components/alexa_devices/media_player.py b/homeassistant/components/alexa_devices/media_player.py index 34fdeeed01da..9aaf0bd19a41 100644 --- a/homeassistant/components/alexa_devices/media_player.py +++ b/homeassistant/components/alexa_devices/media_player.py @@ -22,9 +22,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import _LOGGER -from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator +from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator, alexa_api_call from .entity import AmazonEntity -from .utils import alexa_api_call PARALLEL_UPDATES = 1 @@ -216,16 +215,15 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity): provider = media_type.value if isinstance(media_type, MediaType) else media_type await self.async_call_alexa_music(media_id, provider) - @alexa_api_call async def async_call_alexa_music( self, search_phrase: str, provider_id: str ) -> None: """Call alexa music.""" - await self.coordinator.api.call_alexa_music( - self.device, search_phrase, provider_id - ) + async with alexa_api_call(self.coordinator): + await self.coordinator.api.call_alexa_music( + self.device, search_phrase, provider_id + ) - @alexa_api_call async def async_set_device_volume(self, volume: int) -> None: """Set the device volume.""" _LOGGER.debug( @@ -233,7 +231,8 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity): self.device.serial_number, volume, ) - await self.coordinator.api.set_device_volume(self.device, volume) + async with alexa_api_call(self.coordinator): + await self.coordinator.api.set_device_volume(self.device, volume) async def async_set_volume_level(self, volume: float) -> None: """Set the volume level (0.0 to 1.0).""" @@ -263,12 +262,12 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity): await self.async_set_volume_level(target_volume / 100) self._prev_volume = None - @alexa_api_call async def _send_media_command(self, command: AmazonMediaControls) -> None: _LOGGER.debug( "Sending media command '%s' to %s", command, self.device.serial_number ) - await self.coordinator.api.send_media_command(self.device, command) + async with alexa_api_call(self.coordinator): + await self.coordinator.api.send_media_command(self.device, command) async def async_media_stop(self) -> None: """Send stop command.""" diff --git a/homeassistant/components/alexa_devices/notify.py b/homeassistant/components/alexa_devices/notify.py index c810275afa30..c36c7a6bd2b4 100644 --- a/homeassistant/components/alexa_devices/notify.py +++ b/homeassistant/components/alexa_devices/notify.py @@ -12,9 +12,8 @@ from homeassistant.components.notify import NotifyEntity, NotifyEntityDescriptio from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import AmazonConfigEntry +from .coordinator import AmazonConfigEntry, alexa_api_call from .entity import AmazonEntity -from .utils import alexa_api_call PARALLEL_UPDATES = 1 @@ -80,10 +79,11 @@ class AmazonNotifyEntity(AmazonEntity, NotifyEntity): entity_description: AmazonNotifyEntityDescription - @alexa_api_call async def async_send_message( self, message: str, title: str | None = None, **kwargs: Any ) -> None: """Send a message.""" - - await self.entity_description.method(self.coordinator.api, self.device, message) + async with alexa_api_call(self.coordinator): + await self.entity_description.method( + self.coordinator.api, self.device, message + ) diff --git a/homeassistant/components/alexa_devices/services.py b/homeassistant/components/alexa_devices/services.py index 06beb5258f3e..1a4eca3844b2 100644 --- a/homeassistant/components/alexa_devices/services.py +++ b/homeassistant/components/alexa_devices/services.py @@ -11,7 +11,7 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import DOMAIN, INFO_SKILLS_MAPPING -from .coordinator import AmazonConfigEntry +from .coordinator import AmazonConfigEntry, alexa_api_call ATTR_TEXT_COMMAND = "text_command" ATTR_SOUND = "sound" @@ -85,13 +85,15 @@ async def _async_execute_action(call: ServiceCall, attribute: str) -> None: translation_key="invalid_sound_value", translation_placeholders={"sound": value}, ) - await coordinator.api.call_alexa_sound( - coordinator.data[device.serial_number], value - ) + async with alexa_api_call(): + await coordinator.api.call_alexa_sound( + coordinator.data[device.serial_number], value + ) elif attribute == ATTR_TEXT_COMMAND: - await coordinator.api.call_alexa_text_command( - coordinator.data[device.serial_number], value - ) + async with alexa_api_call(): + await coordinator.api.call_alexa_text_command( + coordinator.data[device.serial_number], value + ) elif attribute == ATTR_INFO_SKILL: info_skill = INFO_SKILLS_MAPPING.get(value) if info_skill not in ALEXA_INFO_SKILLS: @@ -100,9 +102,10 @@ async def _async_execute_action(call: ServiceCall, attribute: str) -> None: translation_key="invalid_info_skill_value", translation_placeholders={"info_skill": value}, ) - await coordinator.api.call_alexa_info_skill( - coordinator.data[device.serial_number], info_skill - ) + async with alexa_api_call(): + await coordinator.api.call_alexa_info_skill( + coordinator.data[device.serial_number], info_skill + ) async def async_send_sound_notification(call: ServiceCall) -> None: diff --git a/homeassistant/components/alexa_devices/switch.py b/homeassistant/components/alexa_devices/switch.py index 274daacfcf6d..54b28e49ad25 100644 --- a/homeassistant/components/alexa_devices/switch.py +++ b/homeassistant/components/alexa_devices/switch.py @@ -14,13 +14,9 @@ from homeassistant.components.switch import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import AmazonConfigEntry +from .coordinator import AmazonConfigEntry, alexa_api_call from .entity import AmazonEntity -from .utils import ( - alexa_api_call, - async_remove_dnd_from_virtual_group, - async_update_unique_id, -) +from .utils import async_remove_dnd_from_virtual_group, async_update_unique_id PARALLEL_UPDATES = 1 @@ -90,7 +86,6 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity): entity_description: AmazonSwitchEntityDescription - @alexa_api_call async def _switch_set_state(self, state: bool) -> None: """Set desired switch state.""" method = getattr(self.coordinator.api, self.entity_description.method) @@ -98,7 +93,8 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity): if TYPE_CHECKING: assert method is not None - await method(self.device, state) + async with alexa_api_call(self.coordinator): + await method(self.device, state) self.coordinator.data[self.device.serial_number].sensors[ self.entity_description.key ].value = state diff --git a/homeassistant/components/alexa_devices/utils.py b/homeassistant/components/alexa_devices/utils.py index 691ce68549ba..1a10d6b0f27a 100644 --- a/homeassistant/components/alexa_devices/utils.py +++ b/homeassistant/components/alexa_devices/utils.py @@ -1,54 +1,19 @@ """Utils for Alexa Devices.""" -from collections.abc import Awaitable, Callable, Coroutine -from functools import wraps -from typing import Any, Concatenate - from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY from aioamazondevices.const.schedules import ( NOTIFICATION_ALARM, NOTIFICATION_REMINDER, NOTIFICATION_TIMER, ) -from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.entity_registry as er from .const import _LOGGER, DOMAIN from .coordinator import AmazonDevicesCoordinator -from .entity import AmazonEntity - - -def alexa_api_call[_T: AmazonEntity, **_P]( - func: Callable[Concatenate[_T, _P], Awaitable[None]], -) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: - """Catch Alexa API call exceptions.""" - - @wraps(func) - async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: - """Wrap all command methods.""" - try: - await func(self, *args, **kwargs) - except CannotConnect as err: - self.coordinator.last_update_success = False - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="cannot_connect_with_error", - translation_placeholders={"error": repr(err)}, - ) from err - except CannotRetrieveData as err: - self.coordinator.last_update_success = False - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="cannot_retrieve_data_with_error", - translation_placeholders={"error": repr(err)}, - ) from err - - return cmd_wrapper async def async_update_unique_id( diff --git a/tests/components/alexa_devices/test_coordinator.py b/tests/components/alexa_devices/test_coordinator.py index 427d38e3a4b2..cc81b994c8bf 100644 --- a/tests/components/alexa_devices/test_coordinator.py +++ b/tests/components/alexa_devices/test_coordinator.py @@ -91,6 +91,48 @@ async def test_coordinator_load_previous_devices_from_registry( assert coordinator.previous_devices == {TEST_DEVICE_1_SN} +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + pytest.param( + CannotConnect, + ConfigEntryState.SETUP_RETRY, + id="cannot_connect", + ), + pytest.param( + CannotRetrieveData, + ConfigEntryState.SETUP_RETRY, + id="cannot_retrieve_data", + ), + pytest.param( + CannotAuthenticate, + ConfigEntryState.SETUP_ERROR, + id="cannot_authenticate", + ), + pytest.param( + ValueError, + ConfigEntryState.SETUP_RETRY, + id="value_error", + ), + ], +) +async def test_async_update_data_errors( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: type[Exception], + expected_state: ConfigEntryState, +) -> None: + """Test _async_update_data error handling.""" + mock_amazon_devices_client.get_devices_data.side_effect = side_effect + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is expected_state + + @pytest.mark.parametrize( ("side_effect", "expected_state"), [ diff --git a/tests/components/alexa_devices/test_utils.py b/tests/components/alexa_devices/test_utils.py index 6a31e55fb1d1..81cf3bfed96b 100644 --- a/tests/components/alexa_devices/test_utils.py +++ b/tests/components/alexa_devices/test_utils.py @@ -3,7 +3,11 @@ from unittest.mock import AsyncMock from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY -from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData +from aioamazondevices.exceptions import ( + CannotAuthenticate, + CannotConnect, + CannotRetrieveData, +) import pytest from homeassistant.components.alexa_devices.const import DOMAIN @@ -25,15 +29,37 @@ ENTITY_ID = "switch.echo_test_do_not_disturb" @pytest.mark.parametrize( ("side_effect", "key", "error"), [ - (CannotConnect, "cannot_connect_with_error", "CannotConnect()"), - (CannotRetrieveData, "cannot_retrieve_data_with_error", "CannotRetrieveData()"), + pytest.param( + CannotAuthenticate, + "invalid_auth", + "CannotAuthenticate()", + id="cannot_authenticate", + ), + pytest.param( + CannotConnect, + "cannot_connect_with_error", + "CannotConnect()", + id="cannot_connect", + ), + pytest.param( + CannotRetrieveData, + "cannot_retrieve_data_with_error", + "CannotRetrieveData()", + id="cannot_retrieve_data", + ), + pytest.param( + ValueError, + "cannot_retrieve_data_with_error", + "ValueError()", + id="value_error", + ), ], ) async def test_alexa_api_call_exceptions( hass: HomeAssistant, mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, - side_effect: Exception, + side_effect: type[Exception], key: str, error: str, ) -> None: From bb5d3fe67f5eb3551bb278bd007fd60fff1b91d9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:39:18 +0200 Subject: [PATCH 036/404] Update uv to 0.11.18 (#173327) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 04161ba385cd..e506ab947661 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -70,7 +70,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.11.17 +uv==0.11.18 voluptuous-openapi==0.3.0 voluptuous-serialize==2.7.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index 7f9388597982..d15219caf22b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "typing-extensions>=4.15.0,<5.0", "ulid-transform==2.2.9", "urllib3>=2.0", - "uv==0.11.17", + "uv==0.11.18", "voluptuous==0.15.2", "voluptuous-serialize==2.7.0", "voluptuous-openapi==0.3.0", diff --git a/requirements.txt b/requirements.txt index f75633bb8bbd..bbc5e3d1cfa7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -55,7 +55,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.11.17 +uv==0.11.18 voluptuous-openapi==0.3.0 voluptuous-serialize==2.7.0 voluptuous==0.15.2 From cbce4b8e7661130a1d9492c33c67c61c83f09a94 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 9 Jun 2026 11:25:08 +0200 Subject: [PATCH 037/404] Add reauthentication flow to Yoto (#173243) --- homeassistant/components/yoto/__init__.py | 12 +- homeassistant/components/yoto/config_flow.py | 24 +++- homeassistant/components/yoto/coordinator.py | 24 +++- .../components/yoto/quality_scale.yaml | 2 +- homeassistant/components/yoto/strings.json | 9 ++ tests/components/yoto/test_config_flow.py | 109 +++++++++++++++++- tests/components/yoto/test_init.py | 87 +++++++++++++- 7 files changed, 258 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/yoto/__init__.py b/homeassistant/components/yoto/__init__.py index efa7898eec53..0d04228e1575 100644 --- a/homeassistant/components/yoto/__init__.py +++ b/homeassistant/components/yoto/__init__.py @@ -4,7 +4,12 @@ import aiohttp from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, OAuth2TokenRequestError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, +) from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, OAuth2Session, @@ -30,6 +35,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: YotoConfigEntry) -> bool try: await session.async_ensure_token_valid() + except OAuth2TokenRequestReauthError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_failed", + ) from err except (aiohttp.ClientError, OAuth2TokenRequestError) as err: raise ConfigEntryNotReady from err diff --git a/homeassistant/components/yoto/config_flow.py b/homeassistant/components/yoto/config_flow.py index 19e5e1040893..6e37e0fde3c6 100644 --- a/homeassistant/components/yoto/config_flow.py +++ b/homeassistant/components/yoto/config_flow.py @@ -1,11 +1,12 @@ """Config flow for the Yoto integration.""" +from collections.abc import Mapping import logging from typing import Any from yoto_api import YotoError, get_account_id -from homeassistant.config_entries import ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.helpers import config_entry_oauth2_flow from .const import _LOGGER, DOMAIN, YOTO_AUDIENCE, YOTO_SCOPES @@ -31,6 +32,20 @@ class YotoOAuth2FlowHandler( "scope": " ".join(YOTO_SCOPES), } + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth and restart the OAuth2 authorization.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Identify the Yoto account from the access token.""" try: @@ -39,5 +54,12 @@ class YotoOAuth2FlowHandler( return self.async_abort(reason="oauth_unauthorized") await self.async_set_unique_id(user_id) + + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=data + ) + self._abort_if_unique_id_configured() return self.async_create_entry(title="Yoto", data=data) diff --git a/homeassistant/components/yoto/coordinator.py b/homeassistant/components/yoto/coordinator.py index ef555aa48bcd..94dd21ffd2da 100644 --- a/homeassistant/components/yoto/coordinator.py +++ b/homeassistant/components/yoto/coordinator.py @@ -3,12 +3,17 @@ from datetime import datetime import aiohttp -from yoto_api import Token, YotoClient, YotoError, YotoPlayer +from yoto_api import AuthenticationError, Token, YotoClient, YotoError, YotoPlayer from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, OAuth2TokenRequestError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.event import async_track_time_interval @@ -57,6 +62,11 @@ class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]): """Set up the coordinator.""" try: await self.client.refresh() + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_failed", + ) from err except YotoError as err: raise ConfigEntryNotReady( translation_domain=DOMAIN, @@ -93,6 +103,11 @@ class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]): try: await self._session.async_ensure_token_valid() + except OAuth2TokenRequestReauthError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_failed", + ) from err except (aiohttp.ClientError, OAuth2TokenRequestError) as err: raise UpdateFailed( translation_domain=DOMAIN, @@ -104,6 +119,11 @@ class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]): try: await self.client.refresh() + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_failed", + ) from err except YotoError as err: raise UpdateFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/yoto/quality_scale.yaml b/homeassistant/components/yoto/quality_scale.yaml index bfbeeed255db..ff7bc62db760 100644 --- a/homeassistant/components/yoto/quality_scale.yaml +++ b/homeassistant/components/yoto/quality_scale.yaml @@ -40,7 +40,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/yoto/strings.json b/homeassistant/components/yoto/strings.json index 7103e75a37f2..30149b3bda24 100644 --- a/homeassistant/components/yoto/strings.json +++ b/homeassistant/components/yoto/strings.json @@ -10,6 +10,8 @@ "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unique_id_mismatch": "The reauthorized account does not match the original Yoto account. Please log in with the same account.", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" }, "create_entry": { @@ -27,10 +29,17 @@ "implementation": "[%key:common::config_flow::description::implementation%]" }, "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "description": "The Yoto integration needs to re-authenticate your account.", + "title": "[%key:common::config_flow::title::reauth%]" } } }, "exceptions": { + "authentication_failed": { + "message": "Yoto credentials are no longer valid. Please reauthenticate your account." + }, "card_detail_failed": { "message": "Could not load Yoto card details: {error}" }, diff --git a/tests/components/yoto/test_config_flow.py b/tests/components/yoto/test_config_flow.py index 98823a45452e..260637758c0c 100644 --- a/tests/components/yoto/test_config_flow.py +++ b/tests/components/yoto/test_config_flow.py @@ -1,13 +1,20 @@ """Tests for the Yoto config flow.""" from http import HTTPStatus +from unittest.mock import MagicMock from urllib.parse import parse_qs, urlparse import jwt import pytest +from yoto_api import AuthenticationError from homeassistant.components.yoto.const import DOMAIN, YOTO_AUDIENCE, YOTO_SCOPES -from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER +from homeassistant.config_entries import ( + SOURCE_DHCP, + SOURCE_REAUTH, + SOURCE_USER, + ConfigEntryState, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow @@ -149,6 +156,106 @@ async def test_already_configured( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures( + "current_request_with_host", "setup_credentials", "mock_setup_entry" +) +async def test_reauth_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, +) -> None: + """Reauthorizing the same account updates the existing entry's token.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.EXTERNAL_STEP + + await _complete_callback( + hass, + result, + hass_client_no_auth, + aioclient_mock, + refresh_token="new-refresh-token", + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data["token"]["refresh_token"] == "new-refresh-token" + + +@pytest.mark.usefixtures( + "current_request_with_host", "setup_credentials", "mock_setup_entry" +) +async def test_reauth_unique_id_mismatch( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, +) -> None: + """Reauthorizing with a different account aborts.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + other_token = jwt.encode( + {"sub": "auth0|other-user"}, "test-secret-long-enough-for-hmac-sha256" + ) + await _complete_callback( + hass, result, hass_client_no_auth, aioclient_mock, access_token=other_token + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + + +@pytest.mark.usefixtures("current_request_with_host", "setup_credentials") +async def test_reauth_recovers_failed_entry( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_yoto_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A successful reauth reloads an entry that failed to authenticate.""" + mock_config_entry.add_to_hass(hass) + mock_yoto_client.refresh.side_effect = AuthenticationError("denied") + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + + mock_yoto_client.refresh.side_effect = None + + result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], {}) + assert result["type"] is FlowResultType.EXTERNAL_STEP + + await _complete_callback( + hass, + result, + hass_client_no_auth, + aioclient_mock, + refresh_token="new-refresh-token", + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + @pytest.mark.parametrize( "access_token", [ diff --git a/tests/components/yoto/test_init.py b/tests/components/yoto/test_init.py index 6a4af13fc161..3acebae3d192 100644 --- a/tests/components/yoto/test_init.py +++ b/tests/components/yoto/test_init.py @@ -5,16 +5,19 @@ from unittest.mock import MagicMock, Mock, patch import aiohttp from freezegun.api import FrozenDateTimeFactory import pytest -from yoto_api import YotoAPIError, YotoError +from yoto_api import AuthenticationError, YotoAPIError, YotoError from homeassistant.components.yoto.const import ( DOMAIN, SCAN_INTERVAL, STATUS_PUSH_INTERVAL, ) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import OAuth2TokenRequestError +from homeassistant.exceptions import ( + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, +) from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, ) @@ -165,6 +168,84 @@ async def test_setup_retries_on_token_validation_error( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_setup_reauth_on_invalid_refresh_token( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """An unrecoverable token refresh at setup starts a reauth flow.""" + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=OAuth2TokenRequestReauthError(request_info=Mock(), domain=DOMAIN), + ): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert any( + flow["context"]["source"] == SOURCE_REAUTH + for flow in hass.config_entries.flow.async_progress() + ) + + +async def test_setup_reauth_on_authentication_error( + hass: HomeAssistant, + mock_yoto_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A rejected access token at setup starts a reauth flow.""" + mock_yoto_client.refresh.side_effect = AuthenticationError("denied") + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert any( + flow["context"]["source"] == SOURCE_REAUTH + for flow in hass.config_entries.flow.async_progress() + ) + + +async def test_poll_reauth_on_authentication_error( + hass: HomeAssistant, + mock_yoto_client: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """A rejected access token during the poll starts a reauth flow.""" + await setup_integration(hass, mock_config_entry) + mock_yoto_client.refresh.side_effect = AuthenticationError("denied") + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert any( + flow["context"]["source"] == SOURCE_REAUTH + for flow in hass.config_entries.flow.async_progress() + ) + + +@pytest.mark.usefixtures("mock_yoto_client") +async def test_poll_reauth_on_invalid_refresh_token( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """An unrecoverable token refresh during the poll starts a reauth flow.""" + await setup_integration(hass, mock_config_entry) + + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=OAuth2TokenRequestReauthError(request_info=Mock(), domain=DOMAIN), + ): + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert any( + flow["context"]["source"] == SOURCE_REAUTH + for flow in hass.config_entries.flow.async_progress() + ) + + async def test_setup_retries_when_mqtt_unavailable( hass: HomeAssistant, mock_yoto_client: MagicMock, From d2277ddbd7af804414ba39d074400b4bcd976326 Mon Sep 17 00:00:00 2001 From: Triggs Date: Tue, 9 Jun 2026 04:50:39 -0500 Subject: [PATCH 038/404] Bump codecov/codecov-action from v6.0.1 to v7.0.0 (#173232) Co-authored-by: Franck Nijhof --- .github/workflows/ci.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a373e03fcda4..3dc1b7cf28f0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1326,7 +1326,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: fail_ci_if_error: true flags: full-suite @@ -1485,7 +1485,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env] @@ -1513,7 +1513,7 @@ jobs: with: pattern: test-results-* - name: Upload test results to Codecov - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: report_type: test_results fail_ci_if_error: true From 9bdb2e21fede7ba578a08f3edf098525300a9847 Mon Sep 17 00:00:00 2001 From: Charles Vestal Date: Tue, 9 Jun 2026 12:15:46 +0200 Subject: [PATCH 039/404] Fix HomeKit crash on integer device trigger subtypes (#173334) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/homekit/type_triggers.py | 4 +- .../components/homekit/test_type_triggers.py | 52 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index ed92e737bfa2..9db9c24a06e1 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -46,7 +46,7 @@ class DeviceTriggerAccessory(HomeAccessory): ent_reg = er.async_get(self.hass) for idx, trigger in enumerate(device_triggers): type_: str = trigger["type"] - subtype: str | None = trigger.get("subtype") + subtype: str | int | None = trigger.get("subtype") unique_id = f"{type_}-{subtype or ''}" entity_id: str | None = None if (entity_id_or_uuid := trigger.get("entity_id")) and ( @@ -61,7 +61,7 @@ class DeviceTriggerAccessory(HomeAccessory): trigger_name_parts.append(state.name) trigger_name_parts.append(type_.replace("_", " ").title()) if subtype: - trigger_name_parts.append(subtype.replace("_", " ").title()) + trigger_name_parts.append(str(subtype).replace("_", " ").title()) trigger_name = cleanup_name_for_homekit(" ".join(trigger_name_parts)) serv_stateless_switch = self.add_preload_service( SERV_STATELESS_PROGRAMMABLE_SWITCH, diff --git a/tests/components/homekit/test_type_triggers.py b/tests/components/homekit/test_type_triggers.py index f15ba359a59b..5651fe9a4f3c 100644 --- a/tests/components/homekit/test_type_triggers.py +++ b/tests/components/homekit/test_type_triggers.py @@ -80,3 +80,55 @@ async def test_programmable_switch_button_fires_on_trigger( assert char.display_name == CHAR_PROGRAMMABLE_SWITCH_EVENT await acc.stop() await hass.async_block_till_done() + + +async def test_programmable_switch_with_integer_subtype( + hass: HomeAssistant, + hk_driver, + demo_cleanup, + entity_registry: er.EntityRegistry, +) -> None: + """Test DeviceTriggerAccessory handles a non-string (integer) trigger subtype. + + Integrations such as Hue provide the button number as an ``int`` subtype. + This previously raised ``AttributeError: 'int' object has no attribute + 'replace'`` and aborted setup of the whole HomeKit bridge. + + Regression test for https://github.com/home-assistant/core/issues/93834 + """ + demo_config_entry = MockConfigEntry(domain="domain") + demo_config_entry.add_to_hass(hass) + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "demo", {"demo": {}}) + await hass.async_block_till_done() + hass.states.async_set("light.ceiling_lights", STATE_OFF) + await hass.async_block_till_done() + + entry = entity_registry.async_get("light.ceiling_lights") + assert entry is not None + device_id = entry.device_id + + # An integration such as Hue uses the button number (an int) as the subtype. + device_triggers = [ + { + "platform": "device", + "domain": "hue", + "device_id": device_id, + "type": "remote_button_short_release", + "subtype": 1, + } + ] + acc = DeviceTriggerAccessory( + hass, + hk_driver, + "DeviceTriggerAccessory", + None, + 1, + None, + device_id=device_id, + device_triggers=device_triggers, + ) + + switch_service = acc.get_service(SERV_STATELESS_PROGRAMMABLE_SWITCH) + configured_name_char = switch_service.get_characteristic(CHAR_CONFIGURED_NAME) + assert configured_name_char.value == "Remote Button Short Release 1" From ee2fb6e150c97ad9f722528b08803d19d71b4397 Mon Sep 17 00:00:00 2001 From: Manu Date: Tue, 9 Jun 2026 12:38:22 +0200 Subject: [PATCH 040/404] Add config flow to SMTP integration (#172019) --- homeassistant/components/smtp/__init__.py | 43 +++- homeassistant/components/smtp/config_flow.py | 240 +++++++++++++++++++ homeassistant/components/smtp/const.py | 2 + homeassistant/components/smtp/icons.json | 7 - homeassistant/components/smtp/issue.py | 49 ++++ homeassistant/components/smtp/manifest.json | 5 +- homeassistant/components/smtp/notify.py | 53 ++-- homeassistant/components/smtp/services.yaml | 1 - homeassistant/components/smtp/strings.json | 77 +++++- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 4 +- tests/components/smtp/conftest.py | 87 +++++++ tests/components/smtp/test_config_flow.py | 223 +++++++++++++++++ tests/components/smtp/test_init.py | 222 +++++++++++++++++ tests/components/smtp/test_notify.py | 49 ---- 15 files changed, 981 insertions(+), 82 deletions(-) create mode 100644 homeassistant/components/smtp/config_flow.py delete mode 100644 homeassistant/components/smtp/icons.json create mode 100644 homeassistant/components/smtp/issue.py delete mode 100644 homeassistant/components/smtp/services.yaml create mode 100644 tests/components/smtp/conftest.py create mode 100644 tests/components/smtp/test_config_flow.py create mode 100644 tests/components/smtp/test_init.py diff --git a/homeassistant/components/smtp/__init__.py b/homeassistant/components/smtp/__init__.py index 5e7fb41c2127..4bcd8462de00 100644 --- a/homeassistant/components/smtp/__init__.py +++ b/homeassistant/components/smtp/__init__.py @@ -1 +1,42 @@ -"""The smtp component.""" +"""The smtp integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_RECIPIENT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import discovery + +from .const import DOMAIN + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up SMTP from a config entry.""" + + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + { + **entry.data, + CONF_NAME: entry.title, + CONF_RECIPIENT: [ + subentry.unique_id for subentry in entry.subentries.values() + ], + }, + {}, + ) + ) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + return True + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle update.""" + hass.config_entries.async_schedule_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return True diff --git a/homeassistant/components/smtp/config_flow.py b/homeassistant/components/smtp/config_flow.py new file mode 100644 index 000000000000..2b9a982e6023 --- /dev/null +++ b/homeassistant/components/smtp/config_flow.py @@ -0,0 +1,240 @@ +"""Config flow for the SMTP integration.""" + +import logging +from smtplib import SMTP, SMTP_SSL, SMTPAuthenticationError +import socket +from ssl import SSLCertVerificationError +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ( + SOURCE_USER, + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryData, + ConfigSubentryFlow, + FlowType, + SubentryFlowContext, + SubentryFlowResult, +) +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_RECIPIENT, + CONF_SENDER, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) +from homeassistant.util.ssl import create_client_context + +from .const import ( + CONF_ENCRYPTION, + CONF_SENDER_NAME, + CONF_SERVER, + DEFAULT_ENCRYPTION, + DEFAULT_HOST, + DEFAULT_PORT, + DEFAULT_TIMEOUT, + DOMAIN, + ENCRYPTION_OPTIONS, + SUBENTRY_TYPE_RECIPIENT, +) + +_LOGGER = logging.getLogger(__name__) + + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_SENDER): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + autocomplete="email", + ), + ), + vol.Optional(CONF_SENDER_NAME): cv.string, + vol.Required(CONF_SERVER, default=DEFAULT_HOST): cv.string, + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_ENCRYPTION, default=DEFAULT_ENCRYPTION): SelectSelector( + SelectSelectorConfig( + options=ENCRYPTION_OPTIONS, + mode=SelectSelectorMode.DROPDOWN, + translation_key="encryption", + ) + ), + vol.Optional(CONF_USERNAME): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + autocomplete="username", + ), + ), + vol.Optional(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ), + ), + vol.Required(CONF_VERIFY_SSL, default=True): cv.boolean, + } +) + + +class MailConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for SMTP.""" + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {SUBENTRY_TYPE_RECIPIENT: RecipientSubentryFlowHandler} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + { + CONF_SERVER: user_input[CONF_SERVER], + CONF_SENDER: user_input[CONF_SENDER], + CONF_USERNAME: user_input.get(CONF_USERNAME), + } + ) + errors = await self.hass.async_add_executor_job(validate_input, user_input) + if not errors: + return self.async_create_entry( + title=user_input.get(CONF_SENDER_NAME, user_input[CONF_SENDER]), + data=user_input, + ) + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + ) + + async def async_on_create_entry(self, result: ConfigFlowResult) -> ConfigFlowResult: + """Start subentry flow after creating main entry.""" + subentry_result = await self.hass.config_entries.subentries.async_init( + (result["result"].entry_id, SUBENTRY_TYPE_RECIPIENT), + context=SubentryFlowContext(source=SOURCE_USER), + ) + result["next_flow"] = ( + FlowType.CONFIG_SUBENTRIES_FLOW, + subentry_result["flow_id"], + ) + return result + + async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult: + """Import config from yaml.""" + + self._async_abort_entries_match(import_info) + + errors = await self.hass.async_add_executor_job(validate_input, import_info) + if not errors: + title = ( + import_info.get(CONF_NAME) + or import_info.get(CONF_SENDER_NAME) + or import_info[CONF_SENDER] + ) + return self.async_create_entry( + title=title, + data=import_info, + subentries=[ + ConfigSubentryData( + subentry_type=SUBENTRY_TYPE_RECIPIENT, + title=recipient, + unique_id=recipient, + data={}, + ) + for recipient in import_info[CONF_RECIPIENT] + ], + ) + + return self.async_abort(reason=errors["base"]) + + +def validate_input(user_input: dict[str, Any]) -> dict[str, str]: + """Validate the user input allows us to connect.""" + errors: dict[str, str] = {} + ssl_context = create_client_context() if user_input[CONF_VERIFY_SSL] else None + mail: SMTP_SSL | SMTP | None = None + try: + if user_input[CONF_ENCRYPTION] == "tls": + mail = SMTP_SSL( + user_input[CONF_SERVER], + user_input[CONF_PORT], + timeout=DEFAULT_TIMEOUT, + context=ssl_context, + ) + else: + mail = SMTP( + user_input[CONF_SERVER], user_input[CONF_PORT], timeout=DEFAULT_TIMEOUT + ) + mail.ehlo_or_helo_if_needed() + if user_input[CONF_ENCRYPTION] == "starttls": + mail.starttls(context=ssl_context) + mail.ehlo() + if user_input.get(CONF_USERNAME) and user_input.get(CONF_PASSWORD): + mail.login(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) + + except SMTPAuthenticationError: + errors["base"] = "invalid_auth" + except SSLCertVerificationError: + errors["base"] = "invalid_cert" + except socket.gaierror, ConnectionRefusedError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + finally: + if mail is not None: + mail.quit() + + return errors + + +class RecipientSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for adding an email recipient.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to add a new recipient.""" + + if user_input is not None: + return self.async_create_entry( + title=user_input.get(CONF_NAME, user_input[CONF_RECIPIENT]), + data={}, + unique_id=user_input[CONF_RECIPIENT], + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_RECIPIENT): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + autocomplete="email", + ), + ), + } + ), + ) diff --git a/homeassistant/components/smtp/const.py b/homeassistant/components/smtp/const.py index b0bfc2e0292f..dc9fccd3d5ab 100644 --- a/homeassistant/components/smtp/const.py +++ b/homeassistant/components/smtp/const.py @@ -19,3 +19,5 @@ DEFAULT_DEBUG: Final = False DEFAULT_ENCRYPTION: Final = "starttls" ENCRYPTION_OPTIONS: Final = ["tls", "starttls", "none"] + +SUBENTRY_TYPE_RECIPIENT: Final = "recipient" diff --git a/homeassistant/components/smtp/icons.json b/homeassistant/components/smtp/icons.json deleted file mode 100644 index a9829425570a..000000000000 --- a/homeassistant/components/smtp/icons.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "services": { - "reload": { - "service": "mdi:reload" - } - } -} diff --git a/homeassistant/components/smtp/issue.py b/homeassistant/components/smtp/issue.py new file mode 100644 index 000000000000..e503e6be70c3 --- /dev/null +++ b/homeassistant/components/smtp/issue.py @@ -0,0 +1,49 @@ +"""Issues for SMTP integration.""" + +from typing import Any + +from homeassistant.const import CONF_NAME, CONF_SENDER +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.util import yaml as yaml_util + +from .const import CONF_SERVER, DOMAIN + + +@callback +def async_deprecate_yaml_issue( + hass: HomeAssistant, config: dict[str, Any], *, import_success: bool = True +) -> None: + """Deprecate yaml issue.""" + if import_success: + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + issue_domain=DOMAIN, + breaks_in_ha_version="2027.1.0", + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "SMTP", + }, + ) + else: + async_create_issue( + hass, + DOMAIN, + ( + f"deprecated_yaml_import_issue_error_{config.get(CONF_NAME, 'unknown')}" + f"_{config[CONF_SENDER]}_{config[CONF_SERVER]}" + ), + breaks_in_ha_version="2027.1.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_error", + translation_placeholders={ + "url": f"/config/integrations/dashboard/add?domain={DOMAIN}", + "config": yaml_util.dump(config), + }, + ) diff --git a/homeassistant/components/smtp/manifest.json b/homeassistant/components/smtp/manifest.json index 66954eebcccf..ad47c06066a2 100644 --- a/homeassistant/components/smtp/manifest.json +++ b/homeassistant/components/smtp/manifest.json @@ -2,7 +2,8 @@ "domain": "smtp", "name": "SMTP", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smtp", - "iot_class": "cloud_push", - "quality_scale": "legacy" + "integration_type": "service", + "iot_class": "cloud_push" } diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index b3c811a5f98a..bd13484c9b28 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -23,6 +23,7 @@ from homeassistant.components.notify import ( PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA, BaseNotificationService, ) +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_DEBUG, CONF_PASSWORD, @@ -35,9 +36,9 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.reload import setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from homeassistant.util.ssl import create_client_context @@ -56,6 +57,7 @@ from .const import ( DOMAIN, ENCRYPTION_OPTIONS, ) +from .issue import async_deprecate_yaml_issue PLATFORMS = [Platform.NOTIFY] @@ -80,30 +82,49 @@ PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( ) -def get_service( +async def async_get_service( hass: HomeAssistant, config: ConfigType, discovery_info: DiscoveryInfoType | None = None, ) -> MailNotificationService | None: """Get the mail notification service.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) - ssl_context = create_client_context() if config[CONF_VERIFY_SSL] else None + if config: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + if result.get("type") is FlowResultType.CREATE_ENTRY or ( + result.get("type") is FlowResultType.ABORT + and result.get("reason") == "already_configured" + ): + async_deprecate_yaml_issue(hass, config) + else: + async_deprecate_yaml_issue(hass, config, import_success=False) + return None + + if discovery_info is None: + return None + + ssl_context = ( + await hass.async_add_executor_job(create_client_context) + if discovery_info[CONF_VERIFY_SSL] + else None + ) mail_service = MailNotificationService( - config[CONF_SERVER], - config[CONF_PORT], - config[CONF_TIMEOUT], - config[CONF_SENDER], - config[CONF_ENCRYPTION], - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), - config[CONF_RECIPIENT], - config.get(CONF_SENDER_NAME), - config[CONF_DEBUG], - config[CONF_VERIFY_SSL], + discovery_info[CONF_SERVER], + discovery_info[CONF_PORT], + discovery_info.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), + discovery_info[CONF_SENDER], + discovery_info[CONF_ENCRYPTION], + discovery_info.get(CONF_USERNAME), + discovery_info.get(CONF_PASSWORD), + discovery_info[CONF_RECIPIENT], + discovery_info.get(CONF_SENDER_NAME), + DEFAULT_DEBUG, + discovery_info[CONF_VERIFY_SSL], ssl_context, ) - if mail_service.connection_is_valid(): + if await hass.async_add_executor_job(mail_service.connection_is_valid): return mail_service return None diff --git a/homeassistant/components/smtp/services.yaml b/homeassistant/components/smtp/services.yaml deleted file mode 100644 index c983a105c939..000000000000 --- a/homeassistant/components/smtp/services.yaml +++ /dev/null @@ -1 +0,0 @@ -reload: diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index f0fed718bf64..dac9a8c62ba7 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -1,13 +1,82 @@ { + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_cert": "Invalid certificate", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "encryption": "Connection security", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "sender": "Sender email", + "sender_name": "Sender name", + "server": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "encryption": "Encryption method used for the SMTP connection.", + "password": "Password or app-specific password for the SMTP account.", + "port": "SMTP server port number.", + "sender": "Email address that will appear in the From field.", + "sender_name": "Display name shown as the email sender.", + "server": "Hostname or IP address of the SMTP server.", + "username": "Username used to authenticate with the SMTP server.", + "verify_ssl": "Enable certificate verification for secure SSL/TLS connections." + } + } + } + }, + "config_subentries": { + "recipient": { + "abort": { + "already_configured": "Recipient is already configured" + }, + "entry_type": "Recipient", + "initiate_flow": { + "user": "Add recipient" + }, + "step": { + "user": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "recipient": "[%key:common::config_flow::data::email%]" + }, + "data_description": { + "name": "Name of the recipient", + "recipient": "Email address of the recipient." + }, + "description": "Set up a recipient for notifications.", + "title": "Recipient" + } + } + } + }, "exceptions": { "remote_path_not_allowed": { "message": "Cannot send email with attachment \"{file_name}\" from directory \"{file_path}\" which is not secure to load data from. Only folders added to `{allow_list}` are accessible. See {url} for more information." } }, - "services": { - "reload": { - "description": "Reloads smtp notify services.", - "name": "[%key:common::action::reload%]" + "issues": { + "deprecated_yaml_import_issue_error": { + "description": "YAML configuration for SMTP is being deprecated, but an error occurred while importing your existing configuration.\n\nVerify that the YAML configuration is valid, then restart Home Assistant to try again. Alternatively remove the SMTP YAML configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually.\n\n**Configuration that could not be imported:**\n\n```{config}```", + "title": "Failed to import SMTP YAML configuration" + } + }, + "selector": { + "encryption": { + "options": { + "none": "None", + "starttls": "STARTTLS", + "tls": "SSL/TLS" + } } } } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ecdd41d6e027..c017d0a1072e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -695,6 +695,7 @@ FLOWS = { "smarty", "smhi", "smlight", + "smtp", "snapcast", "snoo", "snooz", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4e3b7ba8ceee..7303e333ccc4 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6605,8 +6605,8 @@ }, "smtp": { "name": "SMTP", - "integration_type": "hub", - "config_flow": false, + "integration_type": "service", + "config_flow": true, "iot_class": "cloud_push" }, "smud": { diff --git a/tests/components/smtp/conftest.py b/tests/components/smtp/conftest.py new file mode 100644 index 000000000000..0238bc34f9ca --- /dev/null +++ b/tests/components/smtp/conftest.py @@ -0,0 +1,87 @@ +"""Common fixtures for the SMTP tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.smtp.const import ( + CONF_ENCRYPTION, + CONF_SENDER_NAME, + CONF_SERVER, + DOMAIN, + SUBENTRY_TYPE_RECIPIENT, +) +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import ( + CONF_PASSWORD, + CONF_PORT, + CONF_SENDER, + CONF_USERNAME, + CONF_VERIFY_SSL, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.smtp.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="smtp") +def mock_smtp() -> Generator[MagicMock]: + """Mock smtplib.SMTP.""" + + with ( + patch( + "homeassistant.components.smtp.notify.smtplib.SMTP", autospec=True + ) as mock_client, + patch("homeassistant.components.smtp.config_flow.SMTP", new=mock_client), + ): + client = mock_client.return_value + yield client + + +@pytest.fixture(name="smtp_ssl") +def mock_smtp_ssl() -> Generator[MagicMock]: + """Mock SMTP.""" + + with patch( + "homeassistant.components.smtp.config_flow.SMTP_SSL", autospec=True + ) as mock_client: + client = mock_client.return_value + yield client + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Mock smtp configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Home Assistant", + data={ + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "Home Assistant", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_VERIFY_SSL: True, + }, + entry_id="123456789", + subentries_data=[ + ConfigSubentryData( + data={}, + subentry_id="ABCDEF", + subentry_type=SUBENTRY_TYPE_RECIPIENT, + title="Recipient", + unique_id="recipient@example.com", + ) + ], + ) diff --git a/tests/components/smtp/test_config_flow.py b/tests/components/smtp/test_config_flow.py new file mode 100644 index 000000000000..b4fab424276e --- /dev/null +++ b/tests/components/smtp/test_config_flow.py @@ -0,0 +1,223 @@ +"""Test the SMTP config flow.""" + +from smtplib import SMTPAuthenticationError +from socket import gaierror +from ssl import SSLCertVerificationError +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from homeassistant.components.smtp.const import ( + CONF_ENCRYPTION, + CONF_SENDER_NAME, + CONF_SERVER, + DOMAIN, + SUBENTRY_TYPE_RECIPIENT, +) +from homeassistant.config_entries import SOURCE_USER, FlowType +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_RECIPIENT, + CONF_SENDER, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("smtp", "smtp_ssl") +@pytest.mark.parametrize("encryption", ["tls", "starttls"]) +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, encryption: str +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "Home Assistant", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: encryption, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_VERIFY_SSL: True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Home Assistant" + assert result["data"] == { + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "Home Assistant", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: encryption, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_VERIFY_SSL: True, + } + assert len(mock_setup_entry.mock_calls) == 1 + + await hass.async_block_till_done(wait_background_tasks=True) + subentry_flows = hass.config_entries.subentries.async_progress() + assert len(subentry_flows) == 1 + assert result["next_flow"][0] == FlowType.CONFIG_SUBENTRIES_FLOW + + result = await hass.config_entries.subentries.async_configure( + result["next_flow"][1], + user_input={CONF_NAME: "Recipient", CONF_RECIPIENT: "recipient@example.com"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Recipient" + assert result["unique_id"] == "recipient@example.com" + + +@pytest.mark.usefixtures("smtp") +async def test_form_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we abort when entry is already configured.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "Home Assistant", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "tls", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_VERIFY_SSL: True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "text_error"), + [ + (SMTPAuthenticationError(0, ""), "invalid_auth"), + (ConnectionRefusedError, "cannot_connect"), + (gaierror, "cannot_connect"), + (SSLCertVerificationError, "invalid_cert"), + (ValueError, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + smtp: MagicMock, + exception: Exception, + text_error: str, +) -> None: + """Test we handle errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + smtp.login.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "Home Assistant", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_VERIFY_SSL: True, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + smtp.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "Home Assistant", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_VERIFY_SSL: True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Home Assistant" + assert result["data"] == { + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "Home Assistant", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_VERIFY_SSL: True, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("smtp") +async def test_form_recipient_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we abort when subentry is already configured.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_RECIPIENT), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + CONF_NAME: "Rick Astley", + CONF_RECIPIENT: "recipient@example.com", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/smtp/test_init.py b/tests/components/smtp/test_init.py new file mode 100644 index 000000000000..2fd19bdd40a9 --- /dev/null +++ b/tests/components/smtp/test_init.py @@ -0,0 +1,222 @@ +"""Tests for the SMTP integration.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.components.smtp.const import ( + CONF_ENCRYPTION, + CONF_SENDER_NAME, + CONF_SERVER, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_DEBUG, + CONF_NAME, + CONF_PASSWORD, + CONF_PLATFORM, + CONF_PORT, + CONF_RECIPIENT, + CONF_SENDER, + CONF_TIMEOUT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("smtp") +async def test_entry_setup_unload( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test integration setup and unload.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("smtp") +async def test_import( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test yaml import.""" + + await async_setup_component( + hass, + NOTIFY_DOMAIN, + { + NOTIFY_DOMAIN: [ + { + CONF_PLATFORM: DOMAIN, + CONF_NAME: "notifier_name", + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "Home Assistant", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_VERIFY_SSL: True, + CONF_RECIPIENT: "recipient@example.com", + } + ] + }, + ) + + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(entries := hass.config_entries.async_entries(DOMAIN)) == 1 + + assert len(entries[0].subentries) == 1 + + assert entries[0].title == "notifier_name" + assert entries[0].data == { + CONF_PLATFORM: DOMAIN, + CONF_NAME: "notifier_name", + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "Home Assistant", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_VERIFY_SSL: True, + CONF_RECIPIENT: ["recipient@example.com"], + CONF_TIMEOUT: 5, + CONF_DEBUG: False, + } + + assert list(entries[0].subentries.values())[0].unique_id == "recipient@example.com" + + assert issue_registry.async_get_issue( + domain=HOMEASSISTANT_DOMAIN, + issue_id=f"deprecated_yaml_{DOMAIN}", + ) + + +@pytest.mark.usefixtures("smtp") +async def test_import_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test yaml import aborts if already configured.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="Home Assistant", + data={ + CONF_PLATFORM: DOMAIN, + CONF_NAME: "notifier_name", + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "Home Assistant", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_VERIFY_SSL: True, + CONF_RECIPIENT: ["recipient@example.com"], + CONF_DEBUG: False, + CONF_TIMEOUT: 5, + }, + entry_id="123456789", + ) + + config_entry.add_to_hass(hass) + + await async_setup_component( + hass, + NOTIFY_DOMAIN, + { + NOTIFY_DOMAIN: [ + { + CONF_PLATFORM: DOMAIN, + CONF_NAME: "notifier_name", + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "Home Assistant", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_VERIFY_SSL: True, + CONF_RECIPIENT: "recipient@example.com", + } + ] + }, + ) + + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 0 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + assert issue_registry.async_get_issue( + domain=HOMEASSISTANT_DOMAIN, + issue_id=f"deprecated_yaml_{DOMAIN}", + ) + + +async def test_import_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + issue_registry: ir.IssueRegistry, + smtp: MagicMock, +) -> None: + """Test yaml triggers import flow, aborts with errors, and creates error issue.""" + smtp.login.side_effect = ValueError + + await async_setup_component( + hass, + NOTIFY_DOMAIN, + { + NOTIFY_DOMAIN: [ + { + CONF_PLATFORM: DOMAIN, + CONF_NAME: "notifier_name", + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "Home Assistant", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_VERIFY_SSL: True, + CONF_RECIPIENT: "recipient@example.com", + } + ] + }, + ) + + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 0 + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + + assert not issue_registry.async_get_issue( + domain=HOMEASSISTANT_DOMAIN, + issue_id=f"deprecated_yaml_{DOMAIN}", + ) + assert issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=( + "deprecated_yaml_import_issue_error_notifier_name" + "_email@example.com_mail.example.com" + ), + ) diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index ce76de53f8a2..610b9042acb2 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -6,18 +6,12 @@ from unittest.mock import patch import pytest -from homeassistant import config as hass_config -from homeassistant.components import notify from homeassistant.components.smtp.const import DOMAIN from homeassistant.components.smtp.notify import MailNotificationService -from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.setup import async_setup_component from homeassistant.util.ssl import create_client_context -from tests.common import get_fixture_path - class MockSMTP(MailNotificationService): """Test SMTP object that doesn't need a working server.""" @@ -27,49 +21,6 @@ class MockSMTP(MailNotificationService): return msg.as_string(), recipients -async def test_reload_notify(hass: HomeAssistant) -> None: - """Verify we can reload the notify service.""" - - with patch( - "homeassistant.components.smtp.notify.MailNotificationService.connection_is_valid" - ): - assert await async_setup_component( - hass, - notify.DOMAIN, - { - notify.DOMAIN: [ - { - "name": DOMAIN, - "platform": DOMAIN, - "recipient": "test@example.com", - "sender": "test@example.com", - }, - ] - }, - ) - await hass.async_block_till_done() - - assert hass.services.has_service(notify.DOMAIN, DOMAIN) - - yaml_path = get_fixture_path("configuration.yaml", "smtp") - with ( - patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path), - patch( - "homeassistant.components.smtp.notify.MailNotificationService.connection_is_valid" - ), - ): - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() - - assert not hass.services.has_service(notify.DOMAIN, DOMAIN) - assert hass.services.has_service(notify.DOMAIN, "smtp_reloaded") - - @pytest.fixture def message(): """Return MockSMTP object with test data.""" From c28e215ae6de5540afb4320d64b84d23ab96c934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Tue, 9 Jun 2026 12:50:19 +0200 Subject: [PATCH 041/404] Add reauth flow to aqvify (#173287) Co-authored-by: G Johansson --- .../components/aqvify/config_flow.py | 37 +++++++++ .../components/aqvify/coordinator.py | 4 + homeassistant/components/aqvify/strings.json | 15 +++- tests/components/aqvify/test_config_flow.py | 81 ++++++++++++++++++- tests/components/aqvify/test_init.py | 20 +++++ 5 files changed, 154 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aqvify/config_flow.py b/homeassistant/components/aqvify/config_flow.py index 3ba764f0328c..64273e35190d 100644 --- a/homeassistant/components/aqvify/config_flow.py +++ b/homeassistant/components/aqvify/config_flow.py @@ -1,5 +1,6 @@ """Config flow for the Aqvify integration.""" +from collections.abc import Mapping import logging from typing import Any @@ -59,3 +60,39 @@ class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN): "aqvify_url": "https://app.aqvify.com/User", }, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication confirmation.""" + errors = {} + + if user_input is not None: + api_client = AqvifyAPI( + user_input[CONF_API_KEY], + websession=async_get_clientsession(self.hass), + ) + try: + account_data = await api_client.async_get_account_id() + except AqvifyAuthException: + errors["base"] = "invalid_auth" + except ClientResponseError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(account_data.account_id) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data_updates=user_input + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/aqvify/coordinator.py b/homeassistant/components/aqvify/coordinator.py index 858475aad180..6b76a4f98a76 100644 --- a/homeassistant/components/aqvify/coordinator.py +++ b/homeassistant/components/aqvify/coordinator.py @@ -65,6 +65,8 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]): """Fetch device state.""" try: devices = await self.api_client.async_get_devices() + except AqvifyAuthException as err: + raise ConfigEntryAuthFailed(f"Invalid Aqvify API key: {err}") from err except ClientResponseError as err: raise UpdateFailed(f"Error communicating with Aqvify API: {err}") from err except TimeoutError as err: @@ -77,6 +79,8 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]): device_data[ device_key ] = await self.api_client.async_get_device_latest_data(device_key) + except AqvifyAuthException as err: + raise ConfigEntryAuthFailed(f"Invalid Aqvify API key: {err}") from err except ClientResponseError as err: raise UpdateFailed( f"Error communicating with Aqvify API: {err}" diff --git a/homeassistant/components/aqvify/strings.json b/homeassistant/components/aqvify/strings.json index d04db9ef205d..8d26fba74326 100644 --- a/homeassistant/components/aqvify/strings.json +++ b/homeassistant/components/aqvify/strings.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unique_id_mismatch": "The entered API key corresponds to a different account." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -9,9 +11,18 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::aqvify::config::step::user::data_description::api_key%]" + }, + "description": "Reauthentication required. Please enter your updated API key." + }, "user": { "data": { - "api_key": "API key" + "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { "api_key": "Your Aqvify API key" diff --git a/tests/components/aqvify/test_config_flow.py b/tests/components/aqvify/test_config_flow.py index 6c752c128be2..ba2a48d99be6 100644 --- a/tests/components/aqvify/test_config_flow.py +++ b/tests/components/aqvify/test_config_flow.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock from aiohttp import ClientResponseError -from pyaqvify import AqvifyAuthException +from pyaqvify import AqvifyAccount, AqvifyAuthException import pytest from homeassistant.components.aqvify.const import DOMAIN @@ -12,6 +12,8 @@ from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + async def test_full_flow( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_aqvify_client: MagicMock @@ -117,3 +119,80 @@ async def test_same_account_setup( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("return_value", "expected_reason"), + [ + ("test_account_id", "reauth_successful"), + ("test_different_account_id", "unique_id_mismatch"), + ], + ids=["same_account", "different_account"], +) +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_aqvify_client: MagicMock, + return_value: str, + expected_reason: str, +) -> None: + """Test reauthentication.""" + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_aqvify_client.async_get_account_id.return_value = AqvifyAccount( + {"accountId": return_value} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: "test-api-key"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_reason + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (AqvifyAuthException, "invalid_auth"), + ( + ClientResponseError(request_info=None, history=None, status=500), + "cannot_connect", + ), + ], + ids=["invalid_auth", "cannot_connect"], +) +async def test_reauth_flow_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_aqvify_client: MagicMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test reauthentication error handling.""" + + result = await mock_config_entry.start_reauth_flow(hass) + + mock_aqvify_client.async_get_account_id.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: "test-api-key"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + # Make sure the config flow tests finish with FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + mock_aqvify_client.async_get_account_id.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "test-api-key", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/aqvify/test_init.py b/tests/components/aqvify/test_init.py index 99bba4e0134d..1dc22ec52f7a 100644 --- a/tests/components/aqvify/test_init.py +++ b/tests/components/aqvify/test_init.py @@ -73,3 +73,23 @@ async def test_device_registry_integration( # Snapshot the devices to ensure they have the correct structure assert device_entries == snapshot + + +async def test_setup_entry_auth_error_triggers_reauth( + hass: HomeAssistant, + mock_aqvify_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup with auth error triggers reauth flow.""" + mock_config_entry.add_to_hass(hass) + + mock_aqvify_client.async_get_account_id.side_effect = AqvifyAuthException( + "Authentication failed" + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" From da5f4970914ef55410b921468b08c39fe5390c17 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 9 Jun 2026 14:42:38 +0200 Subject: [PATCH 042/404] Ensure we provide strings to vol.In for philips js (#173313) --- homeassistant/components/philips_js/config_flow.py | 6 +++--- tests/components/philips_js/__init__.py | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/philips_js/config_flow.py b/homeassistant/components/philips_js/config_flow.py index 4b1147686eed..597341810f77 100644 --- a/homeassistant/components/philips_js/config_flow.py +++ b/homeassistant/components/philips_js/config_flow.py @@ -45,8 +45,8 @@ USER_SCHEMA = vol.Schema( ): str, vol.Required( CONF_API_VERSION, - default=1, - ): vol.In([1, 5, 6]), + default="1", + ): vol.In(["1", "5", "6"]), } ) @@ -223,7 +223,7 @@ class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN): self._current = user_input try: await self._async_attempt_prepare( - user_input[CONF_HOST], user_input[CONF_API_VERSION], False + user_input[CONF_HOST], int(user_input[CONF_API_VERSION]), False ) except GeneralFailure as exc: LOGGER.error(exc) diff --git a/tests/components/philips_js/__init__.py b/tests/components/philips_js/__init__.py index 09ad61eb99e0..453f4febaab8 100644 --- a/tests/components/philips_js/__init__.py +++ b/tests/components/philips_js/__init__.py @@ -57,6 +57,7 @@ MOCK_SYSTEM_UNPAIRED = { MOCK_USERINPUT = { "host": "1.1.1.1", + "api_version": "1", } MOCK_CONFIG = { From bcb0908d0a42266b46f31ac37489ac4091f80acc Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 9 Jun 2026 15:22:00 +0200 Subject: [PATCH 043/404] Fix reload fails when MQTT entry is not set up (#173335) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Franck Nijhof --- homeassistant/components/mqtt/__init__.py | 6 ++++++ tests/components/mqtt/test_init.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 619e32700772..a59e75ff1fac 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -412,6 +412,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _reload_config(call: ServiceCall) -> None: """Reload the platforms.""" + if not mqtt_config_entry_enabled(hass): + _LOGGER.debug( + "Skipped reloading MQTT integration, " + "the MQTT config entry is not enabled" + ) + return entry: ConfigEntry = next(iter(hass.config_entries.async_entries(DOMAIN))) mqtt_data = hass.data[DATA_MQTT] diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 341851701c52..ef476d1d782c 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -870,6 +870,22 @@ async def test_reload_entry_with_restored_subscriptions( assert recorded_calls[1].payload == "wild-card-payload3" +@pytest.mark.usefixtures("mqtt_client_mock") +async def test_reload_without_entry( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test reloading without a valid config entry set up.""" + # Setup the MQTT integration without entry + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + with caplog.at_level(logging.DEBUG): + await hass.services.async_call(DOMAIN, SERVICE_RELOAD, {}, blocking=True) + assert ( + "Skipped reloading MQTT integration, the MQTT config entry is not enabled" + in caplog.text + ) + + @pytest.mark.parametrize( "hass_config", [ From bbd82ce511f2ba4204a09d15b6d6187629022c6a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 9 Jun 2026 15:24:14 +0200 Subject: [PATCH 044/404] Handle unavailable Zinvolt devices better (#173359) --- .../components/zinvolt/binary_sensor.py | 4 +-- homeassistant/components/zinvolt/entity.py | 11 +++++- .../components/zinvolt/manifest.json | 2 +- homeassistant/components/zinvolt/sensor.py | 12 +++++-- requirements_all.txt | 2 +- .../fixtures/current_state_offline.json | 35 +++++++++++++++++++ .../zinvolt/snapshots/test_diagnostics.ambr | 2 +- .../components/zinvolt/test_binary_sensor.py | 23 ++++++++++-- tests/components/zinvolt/test_sensor.py | 21 +++++++++-- 9 files changed, 99 insertions(+), 13 deletions(-) create mode 100644 tests/components/zinvolt/fixtures/current_state_offline.json diff --git a/homeassistant/components/zinvolt/binary_sensor.py b/homeassistant/components/zinvolt/binary_sensor.py index 52e44ec7c77e..9e4028198f41 100644 --- a/homeassistant/components/zinvolt/binary_sensor.py +++ b/homeassistant/components/zinvolt/binary_sensor.py @@ -30,7 +30,7 @@ POINT_ENTITIES = { class ZinvoltBatteryStateDescription(BinarySensorEntityDescription): """Binary sensor description for Zinvolt battery state.""" - is_on_fn: Callable[[ZinvoltData], bool] + is_on_fn: Callable[[ZinvoltData], bool | None] SENSORS: tuple[ZinvoltBatteryStateDescription, ...] = ( @@ -84,7 +84,7 @@ class ZinvoltBatteryStateBinarySensor(ZinvoltEntity, BinarySensorEntity): ) @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return the state of the binary sensor.""" return self.entity_description.is_on_fn(self.coordinator.data) diff --git a/homeassistant/components/zinvolt/entity.py b/homeassistant/components/zinvolt/entity.py index 932e18fb0957..0e90de132acb 100644 --- a/homeassistant/components/zinvolt/entity.py +++ b/homeassistant/components/zinvolt/entity.py @@ -1,6 +1,6 @@ """Base entity for Zinvolt integration.""" -from zinvolt.models import Unit +from zinvolt.models import OnlineStatus, Unit from homeassistant.const import ATTR_VIA_DEVICE from homeassistant.helpers.device_registry import DeviceInfo @@ -25,6 +25,15 @@ class ZinvoltEntity(CoordinatorEntity[ZinvoltDeviceCoordinator]): serial_number=coordinator.data.battery.serial_number, ) + @property + def available(self) -> bool: + """Return if the entity is available.""" + return ( + super().available + and self.coordinator.data.battery.current_power.online_status + is OnlineStatus.ONLINE + ) + class ZinvoltUnitEntity(ZinvoltEntity): """Base entity for Zinvolt units.""" diff --git a/homeassistant/components/zinvolt/manifest.json b/homeassistant/components/zinvolt/manifest.json index a73f18e6c80b..0157193ba02b 100644 --- a/homeassistant/components/zinvolt/manifest.json +++ b/homeassistant/components/zinvolt/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["zinvolt"], "quality_scale": "bronze", - "requirements": ["zinvolt==0.4.3"] + "requirements": ["zinvolt==1.0.0"] } diff --git a/homeassistant/components/zinvolt/sensor.py b/homeassistant/components/zinvolt/sensor.py index 58633cf78dc5..626204417e4f 100644 --- a/homeassistant/components/zinvolt/sensor.py +++ b/homeassistant/components/zinvolt/sensor.py @@ -21,7 +21,7 @@ from .entity import ZinvoltEntity class ZinvoltBatteryStateDescription(SensorEntityDescription): """Sensor description for Zinvolt battery state.""" - value_fn: Callable[[ZinvoltData], float] + value_fn: Callable[[ZinvoltData], float | None] SENSORS: tuple[ZinvoltBatteryStateDescription, ...] = ( @@ -37,7 +37,13 @@ SENSORS: tuple[ZinvoltBatteryStateDescription, ...] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, - value_fn=lambda state: 0 - state.battery.current_power.power_socket_output, + value_fn=( + lambda state: ( + None + if state.battery.current_power.power_socket_output is None + else 0 - state.battery.current_power.power_socket_output + ) + ), ), ) @@ -74,6 +80,6 @@ class ZinvoltBatteryStateSensor(ZinvoltEntity, SensorEntity): ) @property - def native_value(self) -> float: + def native_value(self) -> float | None: """Return the state of the sensor.""" return self.entity_description.value_fn(self.coordinator.data) diff --git a/requirements_all.txt b/requirements_all.txt index 17ccc5d418dd..dea6dd4ce63d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3469,7 +3469,7 @@ zhong-hong-hvac==1.0.13 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zinvolt -zinvolt==0.4.3 +zinvolt==1.0.0 # homeassistant.components.zoneminder zm-py==0.5.4 diff --git a/tests/components/zinvolt/fixtures/current_state_offline.json b/tests/components/zinvolt/fixtures/current_state_offline.json new file mode 100644 index 000000000000..cda7fb6bdb92 --- /dev/null +++ b/tests/components/zinvolt/fixtures/current_state_offline.json @@ -0,0 +1,35 @@ +{ + "sn": "ZVG011025120088", + "name": "ZVG011025120088", + "onlineStatus": "OFFLINE", + "currentPower": { + "onlineStatus": "OFFLINE" + }, + "smartMode": "CHARGED", + "globalSettings": { + "maxOutput": 800, + "maxOutputLimit": 800, + "maxOutputUnlocked": false, + "batHighCap": 100, + "batUseCap": 10, + "maxChargePower": 800, + "feedModePower": { + "modeType": "FIXED", + "fixedPower": 200, + "pvFeedLimitPower": 800, + "equips": [] + }, + "haveElectricityPrices": false, + "standbyTime": 60 + }, + "tips": [], + "bpd": 0, + "statistic": { + "co2": 0, + "saveAmount": 0, + "totalCapacity": 0 + }, + "isShowStatistic": false, + "meterReaders": [], + "remindManualSocCalibration": true +} diff --git a/tests/components/zinvolt/snapshots/test_diagnostics.ambr b/tests/components/zinvolt/snapshots/test_diagnostics.ambr index 215205463528..aa6cc6b26114 100644 --- a/tests/components/zinvolt/snapshots/test_diagnostics.ambr +++ b/tests/components/zinvolt/snapshots/test_diagnostics.ambr @@ -38,7 +38,7 @@ 'max_power': 800, 'on_grid': True, 'online_status': 'ONLINE', - 'output_current': 4, + 'output_current': 4.0, 'photovoltaic_power': 0, 'power_socket_output': -19, 'state_of_charge': 100.0, diff --git a/tests/components/zinvolt/test_binary_sensor.py b/tests/components/zinvolt/test_binary_sensor.py index 72e14bcd466b..32a855ce5986 100644 --- a/tests/components/zinvolt/test_binary_sensor.py +++ b/tests/components/zinvolt/test_binary_sensor.py @@ -3,14 +3,16 @@ from unittest.mock import AsyncMock, patch from syrupy.assertion import SnapshotAssertion +from zinvolt.models import BatteryState -from homeassistant.const import Platform +from homeassistant.components.zinvolt.const import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE, 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 +from tests.common import MockConfigEntry, async_load_fixture, snapshot_platform async def test_all_entities( @@ -25,3 +27,20 @@ async def test_all_entities( await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_offline_battery_on_grid_unknown( + hass: HomeAssistant, + mock_zinvolt_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """on_grid is unknown when the battery is offline.""" + mock_zinvolt_client.get_battery_status.return_value = BatteryState.from_json( + await async_load_fixture(hass, "current_state_offline.json", DOMAIN) + ) + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("binary_sensor.zinvolt_batterij_grid_connection").state + == STATE_UNAVAILABLE + ) diff --git a/tests/components/zinvolt/test_sensor.py b/tests/components/zinvolt/test_sensor.py index 20e5e5c029b1..8f4714d437a5 100644 --- a/tests/components/zinvolt/test_sensor.py +++ b/tests/components/zinvolt/test_sensor.py @@ -3,14 +3,16 @@ from unittest.mock import AsyncMock, patch from syrupy.assertion import SnapshotAssertion +from zinvolt.models import BatteryState -from homeassistant.const import Platform +from homeassistant.components.zinvolt.const import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE, 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 +from tests.common import MockConfigEntry, async_load_fixture, snapshot_platform async def test_all_entities( @@ -25,3 +27,18 @@ async def test_all_entities( await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_offline_battery_sensors_unknown( + hass: HomeAssistant, + mock_zinvolt_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Sensors report unknown when the battery is offline.""" + mock_zinvolt_client.get_battery_status.return_value = BatteryState.from_json( + await async_load_fixture(hass, "current_state_offline.json", DOMAIN) + ) + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.zinvolt_batterij_battery").state == STATE_UNAVAILABLE + assert hass.states.get("sensor.zinvolt_batterij_power").state == STATE_UNAVAILABLE From 372c6697a0f8e6dded183eeff6cdff7e820c3f20 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 9 Jun 2026 16:41:21 +0200 Subject: [PATCH 045/404] Set Zinvolt max output to 2kW if unlocked (#173367) --- homeassistant/components/zinvolt/number.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zinvolt/number.py b/homeassistant/components/zinvolt/number.py index 5fcc7e01199b..83f799fc4555 100644 --- a/homeassistant/components/zinvolt/number.py +++ b/homeassistant/components/zinvolt/number.py @@ -34,7 +34,11 @@ NUMBERS: tuple[ZinvoltBatteryStateDescription, ...] = ( entity_category=EntityCategory.CONFIG, device_class=NumberDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, - value_fn=lambda state: state.battery.global_settings.max_output, + value_fn=lambda state: ( + 2000 + if state.battery.global_settings.max_output_unlocked + else state.battery.global_settings.max_output + ), set_value_fn=lambda client, battery_id, value: client.set_max_output( battery_id, value ), From 6f09fc074db8bc65d4d8399503b8e31182c0e003 Mon Sep 17 00:00:00 2001 From: Nikolai Rahimi Date: Tue, 9 Jun 2026 11:41:32 -0400 Subject: [PATCH 046/404] Bump mitsubishi-comfort to 0.3.1 (#173362) --- homeassistant/components/mitsubishi_comfort/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mitsubishi_comfort/manifest.json b/homeassistant/components/mitsubishi_comfort/manifest.json index 4bb6c9759090..464c4ffd2249 100644 --- a/homeassistant/components/mitsubishi_comfort/manifest.json +++ b/homeassistant/components/mitsubishi_comfort/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["mitsubishi-comfort==0.3.0"] + "requirements": ["mitsubishi-comfort==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index dea6dd4ce63d..50921fd6e8e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1580,7 +1580,7 @@ millheater==0.14.1 minio==7.1.12 # homeassistant.components.mitsubishi_comfort -mitsubishi-comfort==0.3.0 +mitsubishi-comfort==0.3.1 # homeassistant.components.moat moat-ble==0.1.1 From 145538c56306ccf2d88b897d568f4f171c0ea7be Mon Sep 17 00:00:00 2001 From: Manu Date: Tue, 9 Jun 2026 18:02:50 +0200 Subject: [PATCH 047/404] Improve strings in SMTP integration (#173379) --- homeassistant/components/smtp/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index dac9a8c62ba7..9f6e0eb6fa65 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -22,12 +22,12 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "encryption": "Encryption method used for the SMTP connection.", + "encryption": "Encryption method used for the SMTP connection. **STARTTLS** upgrades a plain connection to encrypted (recommended). **SSL/TLS** uses encryption from the start. **None** sends without encryption.", "password": "Password or app-specific password for the SMTP account.", - "port": "SMTP server port number.", + "port": "Port number used by your SMTP server. Common values are `587` (STARTTLS) and `465` (TLS).", "sender": "Email address that will appear in the From field.", "sender_name": "Display name shown as the email sender.", - "server": "Hostname or IP address of the SMTP server.", + "server": "Hostname or IP address of the SMTP server. For example, `smtp.example.com`.", "username": "Username used to authenticate with the SMTP server.", "verify_ssl": "Enable certificate verification for secure SSL/TLS connections." } From 9a01a0d75815d82d6b8812bae52fe194edb5846a Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:05:45 +0200 Subject: [PATCH 048/404] Remove positional message strings when translation_key is set in homewizard (#173377) --- homeassistant/components/homewizard/coordinator.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index f7a94ea7a46e..9f3c2c5c4ec8 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -79,9 +79,8 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] data = await self.api.combined() except RequestError as ex: - # pylint: disable-next=home-assistant-exception-message-with-translation raise UpdateFailed( - ex, translation_domain=DOMAIN, translation_key="communication_error" + translation_domain=DOMAIN, translation_key="communication_error" ) from ex except DisabledError as ex: @@ -96,9 +95,8 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] self.config_entry.entry_id ) - # pylint: disable-next=home-assistant-exception-message-with-translation raise UpdateFailed( - ex, translation_domain=DOMAIN, translation_key="api_disabled" + translation_domain=DOMAIN, translation_key="api_disabled" ) from ex except UnauthorizedError as ex: From 2b530a1dfa3e1a45098afe5a8d0ae1bb467222d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Tue, 9 Jun 2026 18:29:30 +0200 Subject: [PATCH 049/404] Add exception translations for aqvify (#173361) --- .../components/aqvify/coordinator.py | 65 +++++++++++++++---- homeassistant/components/aqvify/strings.json | 11 ++++ 2 files changed, 64 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/aqvify/coordinator.py b/homeassistant/components/aqvify/coordinator.py index 6b76a4f98a76..4e4cbd2d6498 100644 --- a/homeassistant/components/aqvify/coordinator.py +++ b/homeassistant/components/aqvify/coordinator.py @@ -54,23 +54,53 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]): """Set up the coordinator.""" try: await self.api_client.async_get_account_id() - except AqvifyAuthException as err: - raise ConfigEntryAuthFailed(f"Invalid Aqvify API key: {err}") from err - except (ClientResponseError, TimeoutError) as err: + except AqvifyAuthException: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_api_key", + ) from None + except ClientResponseError as err: raise ConfigEntryNotReady( - f"Failed to connect to Aqvify API: {err}" + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={ + "entry": self.config_entry.title, + }, + ) from err + except TimeoutError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="api_timeout", + translation_placeholders={ + "entry": self.config_entry.title, + }, ) from err async def _async_update_data(self) -> AqvifyCoordinatorData: """Fetch device state.""" try: devices = await self.api_client.async_get_devices() - except AqvifyAuthException as err: - raise ConfigEntryAuthFailed(f"Invalid Aqvify API key: {err}") from err + except AqvifyAuthException: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_api_key", + ) from None except ClientResponseError as err: - raise UpdateFailed(f"Error communicating with Aqvify API: {err}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={ + "entry": self.config_entry.title, + }, + ) from err except TimeoutError as err: - raise UpdateFailed(f"Timeout communicating with Aqvify API: {err}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="api_timeout", + translation_placeholders={ + "entry": self.config_entry.title, + }, + ) from err device_data = {} for device in devices.devices.values(): @@ -79,15 +109,26 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]): device_data[ device_key ] = await self.api_client.async_get_device_latest_data(device_key) - except AqvifyAuthException as err: - raise ConfigEntryAuthFailed(f"Invalid Aqvify API key: {err}") from err + except AqvifyAuthException: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_api_key", + ) from None except ClientResponseError as err: raise UpdateFailed( - f"Error communicating with Aqvify API: {err}" + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={ + "entry": self.config_entry.title, + }, ) from err except TimeoutError as err: raise UpdateFailed( - f"Timeout communicating with Aqvify API: {err}" + translation_domain=DOMAIN, + translation_key="api_timeout", + translation_placeholders={ + "entry": self.config_entry.title, + }, ) from err return AqvifyCoordinatorData( diff --git a/homeassistant/components/aqvify/strings.json b/homeassistant/components/aqvify/strings.json index 8d26fba74326..dace067c0b00 100644 --- a/homeassistant/components/aqvify/strings.json +++ b/homeassistant/components/aqvify/strings.json @@ -40,5 +40,16 @@ "name": "Water level" } } + }, + "exceptions": { + "api_error": { + "message": "An error occurred while communicating with the Aqvify API for {entry}" + }, + "api_timeout": { + "message": "Timeout occurred while communicating with the Aqvify API for {entry}" + }, + "invalid_api_key": { + "message": "Invalid API key. Please verify your API key and try to reauthenticate." + } } } From 89a600dc3468c4ddba3147a0f7c62c04822b0c26 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 9 Jun 2026 13:12:07 -0500 Subject: [PATCH 050/404] Only allow specific protocols with ffmpeg in Wyoming satellite announce (#173381) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/wyoming/assist_satellite.py | 2 ++ tests/components/wyoming/test_satellite.py | 27 ++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/wyoming/assist_satellite.py b/homeassistant/components/wyoming/assist_satellite.py index c2edf1ed8f63..f86f01858521 100644 --- a/homeassistant/components/wyoming/assist_satellite.py +++ b/homeassistant/components/wyoming/assist_satellite.py @@ -348,6 +348,8 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): # Use ffmpeg to convert to raw PCM audio with the appropriate format proc = await asyncio.create_subprocess_exec( self._ffmpeg_manager.binary, + "-protocol_whitelist", + "http,https,file,tcp,tls", "-i", announcement.media_id, "-f", diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index 47455bcc080b..8e6dc84b2e46 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -1671,6 +1671,28 @@ async def test_announce( mock_proc = MagicMock() mock_proc.stdout.read = AsyncMock(side_effect=[pcm_audio, b""]) + async def create_subprocess_exec(*args, **kwargs): + # Verify ffmpeg is called with a list of allowed protocols before -i + protocol_arg_idx: int | None = None + input_arg_idx: int | None = None + for arg_idx, arg in enumerate(args): + if arg == "-protocol_whitelist": + protocol_arg_idx = arg_idx + elif arg == "-i": + input_arg_idx = arg_idx + + assert protocol_arg_idx is not None + assert input_arg_idx is not None + assert protocol_arg_idx < input_arg_idx, ( + "-protocol_whitelist must appear before -i" + ) + assert (protocol_arg_idx + 1) < len(args) + + allowed_protocols = set(args[protocol_arg_idx + 1].split(",")) + assert allowed_protocols == {"file", "http", "https", "tcp", "tls"} + + return mock_proc + with ( patch( "homeassistant.components.wyoming.data.load_wyoming_info", @@ -1684,10 +1706,7 @@ async def test_announce( "homeassistant.components.assist_satellite.entity.async_process_play_media_url", new=async_process_play_media_url, ), - patch( - "asyncio.create_subprocess_exec", - return_value=mock_proc, - ), + patch("asyncio.create_subprocess_exec", new=create_subprocess_exec), ): entry = await setup_config_entry(hass) device: SatelliteDevice = entry.runtime_data.device From 95257e36efec77d4f4e0e4e13a0e5c08fd50c362 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:16:16 +0200 Subject: [PATCH 051/404] Bump github/gh-aw-actions from 0.77.3 to 0.78.1 (#173332) Signed-off-by: dependabot[bot] --- .github/workflows/check-requirements.lock.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/check-requirements.lock.yml b/.github/workflows/check-requirements.lock.yml index 7813634694b6..9df0c7082293 100644 --- a/.github/workflows/check-requirements.lock.yml +++ b/.github/workflows/check-requirements.lock.yml @@ -36,7 +36,7 @@ # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 # - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 -# - github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3 +# - github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1 # # Container images used: # - ghcr.io/github/gh-aw-firewall/agent:0.25.46 @@ -90,7 +90,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3 + uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -352,7 +352,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3 + uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -961,7 +961,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3 + uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1100,7 +1100,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3 + uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1325,7 +1325,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3 + uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1383,7 +1383,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3 + uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} From 8e77ef3e1b26a2a62ee3b739d520c226017cf69a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:16:39 +0200 Subject: [PATCH 052/404] Bump actions/checkout from 6.0.2 to 6.0.3 (#173331) Signed-off-by: dependabot[bot] --- .github/workflows/builder.yml | 12 +++--- .../check-requirements-deterministic.yml | 2 +- .github/workflows/check-requirements.lock.yml | 8 ++-- .github/workflows/ci.yaml | 40 +++++++++---------- .github/workflows/codeql.yml | 2 +- .github/workflows/translations.yml | 2 +- .github/workflows/wheels.yml | 6 +-- 7 files changed, 36 insertions(+), 36 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 5b5669522161..fb16f35026e2 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -38,7 +38,7 @@ jobs: base_image_version: ${{ env.BASE_IMAGE_VERSION }} steps: - name: Checkout the repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -102,7 +102,7 @@ jobs: os: ubuntu-24.04-arm steps: - name: Checkout the repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -245,7 +245,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout the repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -292,7 +292,7 @@ jobs: contents: read steps: - name: Checkout the repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -471,7 +471,7 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -518,7 +518,7 @@ jobs: HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }} steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/check-requirements-deterministic.yml b/.github/workflows/check-requirements-deterministic.yml index 8313fafc6693..c06746052e29 100644 --- a/.github/workflows/check-requirements-deterministic.yml +++ b/.github/workflows/check-requirements-deterministic.yml @@ -40,7 +40,7 @@ jobs: timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Set up Python diff --git a/.github/workflows/check-requirements.lock.yml b/.github/workflows/check-requirements.lock.yml index 9df0c7082293..a80997fa2422 100644 --- a/.github/workflows/check-requirements.lock.yml +++ b/.github/workflows/check-requirements.lock.yml @@ -31,7 +31,7 @@ # - GITHUB_TOKEN # # Custom actions used: -# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 # - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -133,7 +133,7 @@ jobs: env: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - name: Checkout .github and .agents folders - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false sparse-checkout: | @@ -372,7 +372,7 @@ jobs: echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" } >> "$GITHUB_OUTPUT" - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Create gh-aw temp directory @@ -1127,7 +1127,7 @@ jobs: echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" - name: Checkout repository for patch context if: needs.agent.outputs.has_patch == 'true' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false # --- Threat Detection --- diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3dc1b7cf28f0..3b3b4a747272 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -98,7 +98,7 @@ jobs: skip_coverage: ${{ steps.info.outputs.skip_coverage }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Generate partial Python venv restore key @@ -264,7 +264,7 @@ jobs: && github.event.inputs.audit-licenses-only != 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Register problem matchers @@ -291,7 +291,7 @@ jobs: && github.event.inputs.audit-licenses-only != 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Run zizmor @@ -318,7 +318,7 @@ jobs: - script/hassfest/docker/Dockerfile steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Register hadolint problem matcher @@ -341,7 +341,7 @@ jobs: python-version: ${{ fromJson(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} @@ -469,7 +469,7 @@ jobs: && github.event.inputs.audit-licenses-only != 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Install additional OS dependencies @@ -512,7 +512,7 @@ jobs: && github.event.inputs.audit-licenses-only != 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Set up Python @@ -548,7 +548,7 @@ jobs: && github.event.inputs.audit-licenses-only != 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Set up Python @@ -576,7 +576,7 @@ jobs: && github.event_name == 'pull_request' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Dependency review @@ -603,7 +603,7 @@ jobs: python-version: ${{ fromJson(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} @@ -654,7 +654,7 @@ jobs: || github.event.inputs.pylint-only == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Set up Python @@ -707,7 +707,7 @@ jobs: && (needs.info.outputs.tests_glob || needs.info.outputs.test_full_suite == 'true') steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Set up Python @@ -758,7 +758,7 @@ jobs: || github.event.inputs.mypy-only == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Set up Python @@ -825,7 +825,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Install additional OS dependencies @@ -889,7 +889,7 @@ jobs: group: ${{ fromJson(needs.info.outputs.test_groups) }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Install additional OS dependencies @@ -1030,7 +1030,7 @@ jobs: mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Install additional OS dependencies @@ -1179,7 +1179,7 @@ jobs: postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Install additional OS dependencies @@ -1317,7 +1317,7 @@ jobs: if: needs.info.outputs.skip_coverage != 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Download all coverage artifacts @@ -1355,7 +1355,7 @@ jobs: group: ${{ fromJson(needs.info.outputs.test_groups) }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Install additional OS dependencies @@ -1476,7 +1476,7 @@ jobs: - pytest-partial steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Download all coverage artifacts diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 787afd7c547b..93d93c8e3140 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 8d9d4f2e2da9..c639e72973c9 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index ce548475bbae..b781b1aeaef3 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -116,7 +116,7 @@ jobs: os: ubuntu-24.04-arm steps: - name: Checkout the repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -167,7 +167,7 @@ jobs: os: ubuntu-24.04-arm steps: - name: Checkout the repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false From fc8040784e9e1fcacddc0f2396dc57926b74cdd1 Mon Sep 17 00:00:00 2001 From: Will Pike <6687499+pike00@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:18:28 -0500 Subject: [PATCH 053/404] Bump python-ecobee-api to 0.4.1 (#172601) --- homeassistant/components/ecobee/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index b1870106fe5e..0e8105a6bf60 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -10,7 +10,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyecobee"], - "requirements": ["python-ecobee-api==0.4.0"], + "requirements": ["python-ecobee-api==0.4.1"], "single_config_entry": true, "zeroconf": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 50921fd6e8e2..fc2e1a97e29e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2636,7 +2636,7 @@ python-dropbox-api==0.1.3 python-duco-connectivity==0.6.0 # homeassistant.components.ecobee -python-ecobee-api==0.4.0 +python-ecobee-api==0.4.1 # homeassistant.components.etherscan python-etherscan-api==0.0.3 From 6f5f59608cd74fe7f7e0e3b9e3bba7aba015a706 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:41:59 +0200 Subject: [PATCH 054/404] Bump github/codeql-action from 4.36.0 to 4.36.1 (#173333) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 93d93c8e3140..214e2569b34d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,11 +28,11 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 with: category: "/language:python" From f4a9514d3572989b3e68bdc74177e2945bc20255 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:00:48 +0200 Subject: [PATCH 055/404] Remove 'home-assistant-exception-message-with-translation' pylint exception from nfandroidtv (#173398) --- homeassistant/components/nfandroidtv/notify.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py index abf20fea1412..a1734fdf7013 100644 --- a/homeassistant/components/nfandroidtv/notify.py +++ b/homeassistant/components/nfandroidtv/notify.py @@ -160,7 +160,6 @@ class NFAndroidTVNotificationService(BaseNotificationService): auth=imagedata.get(ATTR_IMAGE_AUTH), ) else: - # pylint: disable-next=home-assistant-exception-message-with-translation raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_notification_image", @@ -182,7 +181,6 @@ class NFAndroidTVNotificationService(BaseNotificationService): auth=icondata.get(ATTR_ICON_AUTH), ) else: - # pylint: disable-next=home-assistant-exception-message-with-translation raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_notification_icon", From 76c28444e8dbb7f54b6b3c8b0670bc7d3648cbe6 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:01:14 +0200 Subject: [PATCH 056/404] Remove 'home-assistant-exception-message-with-translation' pylint exception from bsblan (#173394) --- homeassistant/components/bsblan/climate.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index ef068268a81c..2955be4268b6 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -183,7 +183,6 @@ class BSBLANClimate(BSBLanCircuitEntity, ClimateEntity): try: await self.coordinator.client.thermostat(**data, circuit=self._circuit) except BSBLANError as err: - # pylint: disable-next=home-assistant-exception-message-with-translation raise HomeAssistantError( translation_domain=DOMAIN, translation_key="set_data_error", From 8e659530daed60f4da42f22f63fff2ac2b9e31df Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:14:52 +0200 Subject: [PATCH 057/404] Remove 'home-assistant-exception-message-with-translation' pylint exception from mqtt (#173396) --- homeassistant/components/mqtt/__init__.py | 1 - homeassistant/components/mqtt/client.py | 3 --- homeassistant/components/mqtt/models.py | 1 - 3 files changed, 5 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index a59e75ff1fac..47bdd3eee11b 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -294,7 +294,6 @@ async def async_check_config_schema( message = conf_util.format_schema_error( hass, exc, domain, config, integration.documentation ) - # pylint: disable-next=home-assistant-exception-message-with-translation raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_platform_config_message", diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index ff2003513bbf..1f4cca9bdc8f 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -159,7 +159,6 @@ async def async_publish( ) -> None: """Publish message to a MQTT topic.""" if not mqtt_config_entry_enabled(hass): - # pylint: disable-next=home-assistant-exception-message-with-translation raise HomeAssistantError( translation_key="mqtt_not_setup_cannot_publish", translation_domain=DOMAIN, @@ -284,7 +283,6 @@ def async_subscribe_internal( try: mqtt_data = hass.data[DATA_MQTT] except KeyError as exc: - # pylint: disable-next=home-assistant-exception-message-with-translation raise HomeAssistantError( translation_key="mqtt_not_setup_cannot_subscribe", translation_domain=DOMAIN, @@ -292,7 +290,6 @@ def async_subscribe_internal( ) from exc client = mqtt_data.client if not mqtt_config_entry_enabled(hass): - # pylint: disable-next=home-assistant-exception-message-with-translation raise HomeAssistantError( translation_key="mqtt_not_enabled_cannot_subscribe", translation_domain=DOMAIN, diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 3f40e4d2c551..3296f55af0ba 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -73,7 +73,6 @@ class SubscriptionID: subscription_id = self._next_id if subscription_id > MAX_28BIT: - # pylint: disable-next=home-assistant-exception-message-with-translation raise HomeAssistantError( translation_domain=DOMAIN, translation_key="mqtt_max_subscription_id_reached", From 426213dd298221acde7da7fe2f98656bdd8b887c Mon Sep 17 00:00:00 2001 From: Stef Coene Date: Tue, 9 Jun 2026 23:17:31 +0200 Subject: [PATCH 058/404] velbus: allow device and sub-device removal (#168283) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/velbus/__init__.py | 24 +++++++ tests/components/velbus/test_init.py | 74 ++++++++++++++++++++- 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 03d8c4fc063a..4e8f0f0bfc4f 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -133,6 +133,30 @@ async def async_remove_entry(hass: HomeAssistant, entry: VelbusConfigEntry) -> N ) +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: VelbusConfigEntry, + device_entry: dr.DeviceEntry, +) -> bool: + """Allow removing a Velbus device and detach its sub-devices. + + Sub-devices are detached from this config entry when their parent is + removed. If the device is still on the bus, it may be recreated when + the integration is reloaded or started again. + """ + if config_entry.entry_id not in device_entry.config_entries: + return False + dev_reg = dr.async_get(hass) + for sub_device in dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id): + if sub_device.via_device_id == device_entry.id: + dev_reg.async_update_device( + sub_device.id, + remove_config_entry_id=config_entry.entry_id, + via_device_id=None, + ) + return True + + async def async_migrate_entry( hass: HomeAssistant, config_entry: VelbusConfigEntry ) -> bool: diff --git a/tests/components/velbus/test_init.py b/tests/components/velbus/test_init.py index 86742bfa480a..2e825b4b5167 100644 --- a/tests/components/velbus/test_init.py +++ b/tests/components/velbus/test_init.py @@ -7,7 +7,10 @@ from syrupy.assertion import SnapshotAssertion from velbusaio.exceptions import VelbusConnectionFailed from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.velbus import VelbusConfigEntry +from homeassistant.components.velbus import ( + VelbusConfigEntry, + async_remove_config_entry_device, +) from homeassistant.components.velbus.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, CONF_PORT, SERVICE_TURN_ON @@ -221,3 +224,72 @@ async def test_device_registry( device_no_sub = device_registry.async_get_device(identifiers={(DOMAIN, "2")}) assert device_no_sub.via_device_id is None + + +async def test_remove_config_entry_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that any Velbus device can be removed.""" + await init_integration(hass, config_entry) + + # Active device (found on bus) can be removed; scan will recreate it + active_device = device_registry.async_get_device(identifiers={(DOMAIN, "1")}) + assert active_device is not None + result = await async_remove_config_entry_device(hass, config_entry, active_device) + assert result is True + + # Stale device (not on bus) can also be removed + stale_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "999")}, + name="Missing Module", + manufacturer="Velleman", + model="VMBX", + ) + result = await async_remove_config_entry_device(hass, config_entry, stale_device) + assert result is True + device_registry.async_update_device( + stale_device.id, remove_config_entry_id=config_entry.entry_id + ) + + stale_device_after = device_registry.async_get(stale_device.id) + assert ( + stale_device_after is None + or config_entry.entry_id not in stale_device_after.config_entries + ) + + +async def test_remove_config_entry_device_detaches_subdevices( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that removing a device also detaches its sub-devices.""" + await init_integration(hass, config_entry) + + stale_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "999")}, + name="Missing Module", + manufacturer="Velleman", + model="VMBX", + ) + sub_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "999-1")}, + name="Missing Module Channel 1", + manufacturer="Velleman", + model="VMBX", + via_device=(DOMAIN, "999"), + ) + + result = await async_remove_config_entry_device(hass, config_entry, stale_device) + assert result is True + + sub_device_after = device_registry.async_get(sub_device.id) + assert sub_device_after is None or ( + config_entry.entry_id not in sub_device_after.config_entries + and sub_device_after.via_device_id is None + ) From 03eb139f6e67f991f05d34dbf867fd282547a5f8 Mon Sep 17 00:00:00 2001 From: Crocmagnon Date: Wed, 10 Jun 2026 08:15:29 +0200 Subject: [PATCH 059/404] ovhcloud_ai_endpoints: fix typo (#173410) --- homeassistant/components/ovhcloud_ai_endpoints/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ovhcloud_ai_endpoints/strings.json b/homeassistant/components/ovhcloud_ai_endpoints/strings.json index 203bfdb1a610..127691efd415 100644 --- a/homeassistant/components/ovhcloud_ai_endpoints/strings.json +++ b/homeassistant/components/ovhcloud_ai_endpoints/strings.json @@ -74,7 +74,7 @@ "llm_hass_api": "[%key:component::ovhcloud_ai_endpoints::config_subentries::conversation::step::init::data_description::llm_hass_api%]", "prompt": "[%key:component::ovhcloud_ai_endpoints::config_subentries::conversation::step::init::data_description::prompt%]" }, - "description": "Update the prompt and Home Assistant LLM APIs for this conversation agent. Create a new conversation agent to use a different model." + "description": "Update the prompt and the Home Assistant LLM APIs for this conversation agent. Create a new conversation agent to use a different model." } } } From 8111667c1f50b64cf0515f3736ca94807453b5ff Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:26:47 +0200 Subject: [PATCH 060/404] Remove positional message strings when translation_key is set in teslemetry (#173391) --- homeassistant/components/teslemetry/lock.py | 2 -- homeassistant/components/teslemetry/strings.json | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py index a92c7bb8e695..e01bfdb92aee 100644 --- a/homeassistant/components/teslemetry/lock.py +++ b/homeassistant/components/teslemetry/lock.py @@ -140,9 +140,7 @@ class TeslemetryCableLockEntity(TeslemetryRootEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Charge cable Lock cannot be manually locked.""" - # pylint: disable-next=home-assistant-exception-message-with-translation raise ServiceValidationError( - "Insert cable to lock", translation_domain=DOMAIN, translation_key="no_cable", ) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 6041f3d87c47..d8bf1fb28087 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -1141,7 +1141,7 @@ "message": "Missing required scope: {scope}" }, "no_cable": { - "message": "Charge cable will lock automatically when connected" + "message": "Insert cable to lock, charge cable will lock automatically when connected" }, "no_config_entry_for_device": { "message": "No config entry for device ID: {device_id}" From 4a5ee9e4ee80e8e286bc29e46bc89247ff1b9be0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:43:13 +0200 Subject: [PATCH 061/404] Bump astral-sh/setup-uv from 8.1.0 to 8.2.0 (#173418) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3b3b4a747272..b17048595570 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -404,7 +404,7 @@ jobs: echo "version=$(grep '^uv==' requirements.txt | cut -d'=' -f3)" >> "$GITHUB_OUTPUT" - name: Set up uv if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: version: ${{ steps.read-uv-version.outputs.version }} - name: Create Python virtual environment From 54cb4a7946060921759f0d8f755a9a1d2079bdf2 Mon Sep 17 00:00:00 2001 From: Andreas Schneider Date: Wed, 10 Jun 2026 11:01:43 +0200 Subject: [PATCH 062/404] Bump babel to version 2.18.0 (#173424) --- homeassistant/components/holiday/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 024983c07f56..c127560cb82f 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.98", "babel==2.15.0"] + "requirements": ["holidays==0.98", "babel==2.18.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index fc2e1a97e29e..9430bef103ee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -633,7 +633,7 @@ azure-storage-blob==12.24.0 b2sdk==2.10.4 # homeassistant.components.holiday -babel==2.15.0 +babel==2.18.0 # homeassistant.components.baidu baidu-aip==1.6.6 From 27599cbfe3c08c015255fe9436ba641f3efd5be3 Mon Sep 17 00:00:00 2001 From: Michael Davie Date: Wed, 10 Jun 2026 05:17:28 -0400 Subject: [PATCH 063/404] Bump env-canada to 0.15.0 (#173408) Co-authored-by: Claude Opus 4.8 --- homeassistant/components/environment_canada/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index 63c7067792c4..8a5535fd1dc2 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env-canada==0.13.2"] + "requirements": ["env-canada==0.15.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9430bef103ee..6ff47ada1d19 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -937,7 +937,7 @@ enocean-async==0.4.2 enturclient==0.2.4 # homeassistant.components.environment_canada -env-canada==0.13.2 +env-canada==0.15.0 # homeassistant.components.season ephem==4.1.6 From 19880b421497617d7bb20129999d058b54e2dad7 Mon Sep 17 00:00:00 2001 From: Jonathan Laliberte Date: Wed, 10 Jun 2026 05:26:25 -0400 Subject: [PATCH 064/404] Fix roomba charging state and add charging binary sensor (#173304) --- .../components/roomba/binary_sensor.py | 35 +++++- homeassistant/components/roomba/vacuum.py | 5 +- tests/components/roomba/conftest.py | 3 + .../roomba/snapshots/test_binary_sensor.ambr | 102 ++++++++++++++++++ tests/components/roomba/test_binary_sensor.py | 56 ++++++++++ tests/components/roomba/test_vacuum.py | 54 ++++++++++ 6 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 tests/components/roomba/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/roomba/test_binary_sensor.py create mode 100644 tests/components/roomba/test_vacuum.py diff --git a/homeassistant/components/roomba/binary_sensor.py b/homeassistant/components/roomba/binary_sensor.py index b4c5765f53a3..6754ed3b6f91 100644 --- a/homeassistant/components/roomba/binary_sensor.py +++ b/homeassistant/components/roomba/binary_sensor.py @@ -1,6 +1,9 @@ """Roomba binary sensor entities.""" -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -18,10 +21,11 @@ async def async_setup_entry( domain_data = config_entry.runtime_data roomba = domain_data.roomba blid = domain_data.blid + entities: list[BinarySensorEntity] = [RoombaCharging(roomba, blid)] status = roomba_reported_state(roomba).get("bin", {}) if "full" in status: - roomba_vac = RoombaBinStatus(roomba, blid) - async_add_entities([roomba_vac]) + entities.append(RoombaBinStatus(roomba, blid)) + async_add_entities(entities) class RoombaBinStatus(IRobotEntity, BinarySensorEntity): @@ -42,3 +46,28 @@ class RoombaBinStatus(IRobotEntity, BinarySensorEntity): def new_state_filter(self, new_state): """Filter the new state.""" return "bin" in new_state + + +class RoombaCharging(IRobotEntity, BinarySensorEntity): + """Class to hold Roomba charging status.""" + + _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING + + @property + def unique_id(self) -> str: + """Return the ID of this sensor.""" + return f"charging_{self._blid}" + + @property + def is_on(self) -> bool: + """Return the state of the sensor.""" + return ( + roomba_reported_state(self.vacuum) + .get("cleanMissionStatus", {}) + .get("phase") + == "charge" + ) + + def new_state_filter(self, new_state): + """Filter the new state.""" + return "cleanMissionStatus" in new_state diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index a7f7551b687f..cec043ce71b2 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -130,7 +130,10 @@ class IRobotVacuum(IRobotEntity, StateVacuumEntity): state = STATE_MAP[phase] except KeyError: return VacuumActivity.ERROR - if cycle != "none" and state in (VacuumActivity.IDLE, VacuumActivity.DOCKED): + # A robot stopped in the middle of a mission is paused, but one that is + # docked to recharge mid-mission stays docked (the charging binary + # sensor distinguishes it from a user-initiated pause on the floor). + if cycle != "none" and state is VacuumActivity.IDLE: state = VacuumActivity.PAUSED return state diff --git a/tests/components/roomba/conftest.py b/tests/components/roomba/conftest.py index e5d33f95a18f..7f611c419c69 100644 --- a/tests/components/roomba/conftest.py +++ b/tests/components/roomba/conftest.py @@ -54,6 +54,9 @@ def mock_roomba() -> Generator[AsyncMock]: } } mock_roomba.roomba_connected = True + mock_roomba.current_state = "Charging" + mock_roomba.error_code = 0 + mock_roomba.error_message = None with patch( "homeassistant.components.roomba.RoombaFactory.create_roomba", diff --git a/tests/components/roomba/snapshots/test_binary_sensor.ambr b/tests/components/roomba/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..400a3bcf543f --- /dev/null +++ b/tests/components/roomba/snapshots/test_binary_sensor.ambr @@ -0,0 +1,102 @@ +# serializer version: 1 +# name: test_entities[binary_sensor.test_roomba_bin_full-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_roomba_bin_full', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Bin full', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bin full', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bin_full', + 'unique_id': 'bin_blid123', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.test_roomba_bin_full-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Bin full', + }), + 'context': , + 'entity_id': 'binary_sensor.test_roomba_bin_full', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.test_roomba_charging-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_roomba_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charging', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'charging_blid123', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.test_roomba_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'Test Roomba Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.test_roomba_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/roomba/test_binary_sensor.py b/tests/components/roomba/test_binary_sensor.py new file mode 100644 index 000000000000..a2cefeb95d78 --- /dev/null +++ b/tests/components/roomba/test_binary_sensor.py @@ -0,0 +1,56 @@ +"""Tests for the Roomba binary sensor platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_roomba: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test roomba binary sensor entities.""" + with patch("homeassistant.components.roomba.PLATFORMS", [Platform.BINARY_SENSOR]): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("phase", "expected"), + [ + ("charge", STATE_ON), + ("run", STATE_OFF), + ], +) +async def test_charging( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_roomba: AsyncMock, + phase: str, + expected: str, +) -> None: + """Test the charging binary sensor reflects the dock charge phase.""" + mock_roomba.master_state["state"]["reported"]["cleanMissionStatus"]["phase"] = phase + + with patch("homeassistant.components.roomba.PLATFORMS", [Platform.BINARY_SENSOR]): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test_roomba_charging") + assert state is not None + assert state.state == expected diff --git a/tests/components/roomba/test_vacuum.py b/tests/components/roomba/test_vacuum.py new file mode 100644 index 000000000000..c352adaba3c6 --- /dev/null +++ b/tests/components/roomba/test_vacuum.py @@ -0,0 +1,54 @@ +"""Tests for the Roomba vacuum platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.vacuum import VacuumActivity +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +ENTITY_ID = "vacuum.test_roomba" + + +@pytest.mark.parametrize( + ("phase", "cycle", "expected"), + [ + ("charge", "none", VacuumActivity.DOCKED), + # Docked to recharge in the middle of a mission stays docked instead of + # being reported as paused (regression test for #148287). + ("charge", "clean", VacuumActivity.DOCKED), + ("hmMidMsn", "clean", VacuumActivity.CLEANING), + ("hmPostMsn", "clean", VacuumActivity.RETURNING), + ("run", "clean", VacuumActivity.CLEANING), + ("pause", "clean", VacuumActivity.PAUSED), + # Stopped on the floor mid-mission is a paused state. + ("stop", "clean", VacuumActivity.PAUSED), + ("stop", "none", VacuumActivity.IDLE), + ("stuck", "clean", VacuumActivity.ERROR), + ], +) +async def test_vacuum_activity( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_roomba: AsyncMock, + phase: str, + cycle: str, + expected: VacuumActivity, +) -> None: + """Test the vacuum activity mapping from the reported mission status.""" + mock_roomba.master_state["state"]["reported"]["cleanMissionStatus"] = { + "cycle": cycle, + "phase": phase, + } + + with patch("homeassistant.components.roomba.PLATFORMS", [Platform.VACUUM]): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == expected From f3a3f4cde41141de24b2ec4711895f2de6806e69 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:47:00 +0200 Subject: [PATCH 065/404] Remove positional message strings when translation_key is set in blink (#173390) --- homeassistant/components/blink/camera.py | 4 -- homeassistant/components/blink/strings.json | 2 +- tests/components/blink/test_camera.py | 50 +++++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 tests/components/blink/test_camera.py diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index 57865984a458..577aa9b4f05b 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -169,9 +169,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): try: await self._camera.save_recent_clips(output_dir=file_path) except OSError as err: - # pylint: disable-next=home-assistant-exception-message-with-translation raise ServiceValidationError( - str(err), translation_domain=DOMAIN, translation_key="cant_write", ) from err @@ -191,9 +189,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): try: await self._camera.video_to_file(filename) except OSError as err: - # pylint: disable-next=home-assistant-exception-message-with-translation raise ServiceValidationError( - str(err), translation_domain=DOMAIN, translation_key="cant_write", ) from err diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index 7bf075f18c9d..abeaef005ae8 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -54,7 +54,7 @@ }, "exceptions": { "cant_write": { - "message": "Can't write to file." + "message": "Can't write to file, check logs for details." }, "failed_arm": { "message": "Blink failed to arm camera." diff --git a/tests/components/blink/test_camera.py b/tests/components/blink/test_camera.py new file mode 100644 index 000000000000..df3e177251ed --- /dev/null +++ b/tests/components/blink/test_camera.py @@ -0,0 +1,50 @@ +"""Test the Blink camera platform.""" + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.blink.camera import BlinkCamera +from homeassistant.components.blink.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("entity_method", "api_method"), + [ + pytest.param("save_recent_clips", "save_recent_clips", id="save_recent_clips"), + pytest.param("save_video", "video_to_file", id="save_video"), + ], +) +async def test_cant_write_raises_service_validation_error( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, + camera: MagicMock, + tmp_path: Path, + entity_method: str, + api_method: str, +) -> None: + """Test that OSError raises ServiceValidationError with the error as placeholder.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + hass.config.allowlist_external_dirs = {tmp_path} + getattr(camera, api_method).side_effect = OSError("disk full") + + coordinator = mock_config_entry.runtime_data + camera_entity = BlinkCamera(coordinator, camera.name, camera) + camera_entity.hass = hass + + with pytest.raises(ServiceValidationError) as exc_info: + await getattr(camera_entity, entity_method)(str(tmp_path)) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "cant_write" + assert exc_info.value.__cause__.args[0] == "disk full" From 102cb4b69e52a507c70ce6cb3e58dd1b875f4cc0 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 10 Jun 2026 11:48:49 +0200 Subject: [PATCH 066/404] Add pylint enforce dt.now checker (#173005) --- homeassistant/components/lg_thinq/sensor.py | 2 + homeassistant/components/metoffice/weather.py | 18 +- .../components/powerfox/coordinator.py | 4 +- homeassistant/components/starlink/time.py | 6 +- homeassistant/components/tado/coordinator.py | 4 +- pylint/plugins/README.md | 17 ++ .../pylint_home_assistant/checkers/now.py | 42 ++++ .../pylint_home_assistant/checkers/utcnow.py | 109 +------- .../helpers/datetime_now.py | 130 ++++++++++ tests/components/google/test_init.py | 8 +- tests/pylint/conftest.py | 9 + tests/pylint/test_now.py | 238 ++++++++++++++++++ 12 files changed, 466 insertions(+), 121 deletions(-) create mode 100644 pylint/plugins/pylint_home_assistant/checkers/now.py create mode 100644 pylint/plugins/pylint_home_assistant/helpers/datetime_now.py create mode 100644 tests/pylint/test_now.py diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index 80b1e930db4b..bf271e8908fd 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -735,6 +735,7 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity): value = self.data.value if isinstance(value, time): + # pylint: disable-next=home-assistant-enforce-now local_now = datetime.now( tz=dt_util.get_time_zone(self.coordinator.hass.config.time_zone) ) @@ -847,6 +848,7 @@ class ThinQEnergySensorEntity(ThinQEntity, SensorEntity): async def _async_update_and_schedule(self) -> None: """Update the state of the sensor.""" + # pylint: disable-next=home-assistant-enforce-now local_now = datetime.now( dt_util.get_time_zone(self.coordinator.hass.config.time_zone) ) diff --git a/homeassistant/components/metoffice/weather.py b/homeassistant/components/metoffice/weather.py index ba1b816673b9..7ab8ffe4fc1b 100644 --- a/homeassistant/components/metoffice/weather.py +++ b/homeassistant/components/metoffice/weather.py @@ -264,9 +264,9 @@ class MetOfficeWeather( self.forecast_coordinators["daily"], ) timesteps = coordinator.data.timesteps - start_datetime = datetime.now(tz=timesteps[0]["time"].tzinfo).replace( - hour=0, minute=0, second=0, microsecond=0 - ) + start_datetime = datetime.now( # pylint: disable=home-assistant-enforce-now + tz=timesteps[0]["time"].tzinfo + ).replace(hour=0, minute=0, second=0, microsecond=0) return [ _build_daily_forecast_data(timestep) for timestep in timesteps @@ -282,9 +282,9 @@ class MetOfficeWeather( ) timesteps = coordinator.data.timesteps - start_datetime = datetime.now(tz=timesteps[0]["time"].tzinfo).replace( - minute=0, second=0, microsecond=0 - ) + start_datetime = datetime.now( # pylint: disable=home-assistant-enforce-now + tz=timesteps[0]["time"].tzinfo + ).replace(minute=0, second=0, microsecond=0) return [ _build_hourly_forecast_data(timestep) for timestep in timesteps @@ -299,9 +299,9 @@ class MetOfficeWeather( self.forecast_coordinators["twice_daily"], ) timesteps = coordinator.data.timesteps - start_datetime = datetime.now(tz=timesteps[0]["time"].tzinfo).replace( - hour=0, minute=0, second=0, microsecond=0 - ) + start_datetime = datetime.now( # pylint: disable=home-assistant-enforce-now + tz=timesteps[0]["time"].tzinfo + ).replace(hour=0, minute=0, second=0, microsecond=0) return [ _build_twice_daily_forecast_data(timestep) for timestep in timesteps diff --git a/homeassistant/components/powerfox/coordinator.py b/homeassistant/components/powerfox/coordinator.py index 9cb45bd94d00..628edc72b010 100644 --- a/homeassistant/components/powerfox/coordinator.py +++ b/homeassistant/components/powerfox/coordinator.py @@ -95,7 +95,9 @@ class PowerfoxReportDataUpdateCoordinator(PowerfoxBaseCoordinator[DeviceReport]) async def _async_fetch_data(self) -> DeviceReport: """Fetch report data from the Powerfox API.""" - local_now = datetime.now(tz=dt_util.get_time_zone(self.hass.config.time_zone)) + local_now = datetime.now( # pylint: disable=home-assistant-enforce-now + tz=dt_util.get_time_zone(self.hass.config.time_zone) + ) return await self.client.report( device_id=self.device.id, year=local_now.year, diff --git a/homeassistant/components/starlink/time.py b/homeassistant/components/starlink/time.py index f072810cbfa8..f5b1737044f6 100644 --- a/homeassistant/components/starlink/time.py +++ b/homeassistant/components/starlink/time.py @@ -74,9 +74,9 @@ def _utc_minutes_to_time(utc_minutes: int, timezone: tzinfo) -> time: def _time_to_utc_minutes(t: time, timezone: tzinfo) -> int: try: - zoned_time = datetime.now(timezone).replace( - hour=t.hour, minute=t.minute, second=0, microsecond=0 - ) + zoned_time = datetime.now( # pylint: disable=home-assistant-enforce-now + timezone + ).replace(hour=t.hour, minute=t.minute, second=0, microsecond=0) except ValueError as exc: raise HomeAssistantError from exc utc_time = zoned_time.astimezone(UTC).time() diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index 6ee33f39cb39..5b38768cb513 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -155,7 +155,9 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Tado resets somewhere between 12:00 and 13:00, Berlin time # So let's pretend we're in Berlin... - reset_time = datetime.now(ZoneInfo("Europe/Berlin")) + reset_time = datetime.now( # pylint: disable=home-assistant-enforce-now + ZoneInfo("Europe/Berlin") + ) today_reset = datetime.combine( reset_time.date(), diff --git a/pylint/plugins/README.md b/pylint/plugins/README.md index c9433c38cafc..7775652dee24 100644 --- a/pylint/plugins/README.md +++ b/pylint/plugins/README.md @@ -105,6 +105,7 @@ Every check has a code following the | `W7421` | [`home-assistant-tests-direct-async-migrate-entry`](#w7421-home-assistant-tests-direct-async-migrate-entry) | Tests should not call an integration's `async_migrate_entry` directly | | `W7422` | [`home-assistant-tests-direct-async-setup`](#w7422-home-assistant-tests-direct-async-setup) | Tests should not call an integration's `async_setup` directly | | `C7414` | [`home-assistant-enforce-utcnow`](#c7414-home-assistant-enforce-utcnow) | Use `homeassistant.util.dt.utcnow` instead of `datetime.now(UTC)` | +| `C7425` | [`home-assistant-enforce-now`](#c7425-home-assistant-enforce-now) | Use `homeassistant.util.dt.now` instead of `datetime.now()` | | `W7423` | [`home-assistant-missing-entity-unique-id`](#w7423-home-assistant-missing-entity-unique-id) | Entity class does not statically guarantee a non-None unique id | | `W7424` | [`home-assistant-entity-unique-id-static`](#w7424-home-assistant-entity-unique-id-static) | Entity class sets `_attr_unique_id` to a static string at class level | | `C7412` | [`home-assistant-entity-description-redundant-default`](#c7412-home-assistant-entity-description-redundant-default) | Setting an EntityDescription field to its default value is redundant | @@ -458,6 +459,22 @@ lookup of `UTC` on every call, while keeping the codebase consistent in how the current UTC time is obtained. +## `home_assistant_enforce_now` checker + +Ensures the Home Assistant helper is used to get the current local time. + +### `C7425`: `home-assistant-enforce-now` + +Use `homeassistant.util.dt.now()` instead of `datetime.datetime.now()` +when called with a non-UTC time zone to create an aware `datetime`. The +helper returns an aware `datetime` in the given time zone (defaulting to +`DEFAULT_TIME_ZONE`), keeping the codebase consistent in how the current +local time is obtained. The UTC case (`datetime.now(UTC)`) is handled by +the [`home-assistant-enforce-utcnow`](#c7414-home-assistant-enforce-utcnow) +checker, and `datetime.now()` with no argument is not flagged since it +returns a naive local `datetime`. + + ## `home_assistant_entity_unique_id` checker Quality-scale-gated checker for the [`entity-unique-id`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-unique-id) diff --git a/pylint/plugins/pylint_home_assistant/checkers/now.py b/pylint/plugins/pylint_home_assistant/checkers/now.py new file mode 100644 index 000000000000..55cdf593bace --- /dev/null +++ b/pylint/plugins/pylint_home_assistant/checkers/now.py @@ -0,0 +1,42 @@ +"""Checker that enforces ``homeassistant.util.dt.now`` over ``datetime.now(tz)``. + +Home Assistant exposes ``homeassistant.util.dt.now`` -- a helper that returns an +aware ``datetime`` in the given time zone (defaulting to ``DEFAULT_TIME_ZONE``). +Calling ``datetime.datetime.now(tz)`` directly with a time zone argument does the +same thing but bypasses the helper. Using ``dt_util.now`` keeps the codebase +consistent in how the current local time is obtained. + +The UTC special case (``datetime.now(UTC)``) is intentionally left to the +``home-assistant-enforce-utcnow`` checker, which steers it to the faster +``dt_util.utcnow`` partial. ``datetime.now()`` with no argument returns a naive +local ``datetime`` and is therefore not equivalent to ``dt_util.now()``; it is not +flagged. +""" + +from pylint.lint import PyLinter + +from pylint_home_assistant.helpers.datetime_now import HassEnforceDatetimeNowChecker + + +class HassEnforceNowChecker(HassEnforceDatetimeNowChecker): + """Checker that flags ``datetime.now(tz)`` calls with a non-UTC time zone.""" + + name = "home_assistant_enforce_now" + msgs = { + "C7425": ( + "Use `homeassistant.util.dt.now()` instead of `datetime.now()`", + "home-assistant-enforce-now", + "Used when ``datetime.datetime.now()`` is called with a non-UTC " + "time zone to create an aware ``datetime``. Use the " + "``homeassistant.util.dt.now`` helper instead. The UTC case is " + "handled by the ``home-assistant-enforce-utcnow`` checker.", + ), + } + + message = "home-assistant-enforce-now" + flags_utc = False + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassEnforceNowChecker(linter)) diff --git a/pylint/plugins/pylint_home_assistant/checkers/utcnow.py b/pylint/plugins/pylint_home_assistant/checkers/utcnow.py index 507b26600ab7..72746f3428fc 100644 --- a/pylint/plugins/pylint_home_assistant/checkers/utcnow.py +++ b/pylint/plugins/pylint_home_assistant/checkers/utcnow.py @@ -6,44 +6,15 @@ helper avoids the per-call global lookup of ``UTC`` and keeps the codebase consistent in how the current UTC time is obtained. """ -from astroid import nodes -from pylint.checkers import BaseChecker from pylint.lint import PyLinter -# ``homeassistant.util.dt`` defines ``utcnow`` itself, so it must call -# ``datetime.datetime.now(UTC)`` directly. -_SKIP_MODULES = frozenset({"homeassistant.util.dt"}) +from pylint_home_assistant.helpers.datetime_now import HassEnforceDatetimeNowChecker -def _attribute_path(node: nodes.NodeNG) -> tuple[str, ...] | None: - """Return the dotted-name path of an Attribute/Name chain, or ``None``.""" - parts: list[str] = [] - while isinstance(node, nodes.Attribute): - parts.append(node.attrname) - node = node.expr - if not isinstance(node, nodes.Name): - return None - parts.append(node.name) - return tuple(reversed(parts)) - - -def _is_zoneinfo_utc(node: nodes.NodeNG) -> bool: - """Return True if *node* is ``ZoneInfo("UTC")`` or ``*.ZoneInfo("UTC")``.""" - match node: - case nodes.Call( - func=nodes.Name(name="ZoneInfo") | nodes.Attribute(attrname="ZoneInfo"), - args=[nodes.Const(value="UTC")], - keywords=[], - ): - return True - return False - - -class HassEnforceUtcnowChecker(BaseChecker): +class HassEnforceUtcnowChecker(HassEnforceDatetimeNowChecker): """Checker that flags ``datetime.now(UTC)`` calls.""" name = "home_assistant_enforce_utcnow" - priority = -1 msgs = { "C7414": ( "Use `homeassistant.util.dt.utcnow()` instead of `datetime.now(UTC)`", @@ -54,81 +25,9 @@ class HassEnforceUtcnowChecker(BaseChecker): "and avoids the global lookup of ``UTC`` on every call.", ), } - options = () - _enabled: bool - _datetime_class_paths: set[tuple[str, ...]] - _utc_paths: set[tuple[str, ...]] - - def visit_module(self, node: nodes.Module) -> None: - """Collect ``datetime`` bindings introduced by module-level imports.""" - self._datetime_class_paths = set() - self._utc_paths = set() - self._enabled = node.name not in _SKIP_MODULES - if not self._enabled: - return - - for stmt in node.body: - match stmt: - case nodes.ImportFrom(modname="datetime", names=names): - for name, alias in names: - local = alias or name - match name: - case "datetime": - self._datetime_class_paths.add((local,)) - case "UTC": - self._utc_paths.add((local,)) - case "timezone": - self._utc_paths.add((local, "utc")) - case nodes.ImportFrom(modname="homeassistant.util", names=names): - # ``homeassistant.util.dt`` re-exports ``UTC`` from - # ``datetime``, so ``dt_util.UTC`` must be flagged too. - for name, alias in names: - if name == "dt": - local = alias or name - self._utc_paths.add((local, "UTC")) - case nodes.ImportFrom(modname="homeassistant.util.dt", names=names): - for name, alias in names: - if name == "UTC": - self._utc_paths.add((alias or name,)) - case nodes.Import(names=names): - for name, alias in names: - match name: - case "datetime": - local = alias or name - self._datetime_class_paths.add((local, "datetime")) - self._utc_paths.add((local, "UTC")) - self._utc_paths.add((local, "timezone", "utc")) - case "homeassistant.util.dt" if alias: - self._utc_paths.add((alias, "UTC")) - - def visit_call(self, node: nodes.Call) -> None: - """Check for ``datetime.now(UTC)`` calls.""" - if not self._enabled: - return - - match node: - case nodes.Call( - func=nodes.Attribute(attrname="now", expr=expr), - args=[arg], - keywords=[], - ): - pass - case nodes.Call( - func=nodes.Attribute(attrname="now", expr=expr), - args=[], - keywords=[nodes.Keyword(arg="tz", value=arg)], - ): - pass - case _: - return - - if _attribute_path(expr) not in self._datetime_class_paths: - return - if _attribute_path(arg) not in self._utc_paths and not _is_zoneinfo_utc(arg): - return - - self.add_message("home-assistant-enforce-utcnow", node=node) + message = "home-assistant-enforce-utcnow" + flags_utc = True def register(linter: PyLinter) -> None: diff --git a/pylint/plugins/pylint_home_assistant/helpers/datetime_now.py b/pylint/plugins/pylint_home_assistant/helpers/datetime_now.py new file mode 100644 index 000000000000..436934ebe58d --- /dev/null +++ b/pylint/plugins/pylint_home_assistant/helpers/datetime_now.py @@ -0,0 +1,130 @@ +"""Shared logic for the ``datetime.now`` enforcement checkers. + +Both the ``home-assistant-enforce-now`` and ``home-assistant-enforce-utcnow`` +checkers look for ``datetime.datetime.now()`` calls and differ only in which +time zone argument they care about. The common detection lives here; each checker +module just declares its message and whether it fires on the UTC case. +""" + +from astroid import nodes +from pylint.checkers import BaseChecker + +# ``homeassistant.util.dt`` defines ``now``/``utcnow`` itself, so it must call +# ``datetime.datetime.now(...)`` directly. +SKIP_MODULES = frozenset({"homeassistant.util.dt"}) + + +def attribute_path(node: nodes.NodeNG) -> tuple[str, ...] | None: + """Return the dotted-name path of an Attribute/Name chain, or ``None``.""" + parts: list[str] = [] + while isinstance(node, nodes.Attribute): + parts.append(node.attrname) + node = node.expr + if not isinstance(node, nodes.Name): + return None + parts.append(node.name) + return tuple(reversed(parts)) + + +def is_zoneinfo_utc(node: nodes.NodeNG) -> bool: + """Return True if *node* is ``ZoneInfo("UTC")`` or ``*.ZoneInfo("UTC")``.""" + match node: + case nodes.Call( + func=nodes.Name(name="ZoneInfo") | nodes.Attribute(attrname="ZoneInfo"), + args=[nodes.Const(value="UTC")], + keywords=[], + ): + return True + return False + + +class HassEnforceDatetimeNowChecker(BaseChecker): + """Base checker for ``datetime.datetime.now()`` calls. + + Subclasses must define ``name`` and ``msgs`` and set: + + - ``message``: the message symbol to emit. + - ``flags_utc``: ``True`` to fire on the UTC case, ``False`` to fire on every + other (non-UTC) time zone. + """ + + priority = -1 + options = () + + message: str + flags_utc: bool + + _enabled: bool + _datetime_class_paths: set[tuple[str, ...]] + _utc_paths: set[tuple[str, ...]] + + def visit_module(self, node: nodes.Module) -> None: + """Collect ``datetime`` bindings introduced by module-level imports.""" + self._datetime_class_paths = set() + self._utc_paths = set() + self._enabled = node.name not in SKIP_MODULES + if not self._enabled: + return + + for stmt in node.body: + match stmt: + case nodes.ImportFrom(modname="datetime", names=names): + for name, alias in names: + local = alias or name + match name: + case "datetime": + self._datetime_class_paths.add((local,)) + case "UTC": + self._utc_paths.add((local,)) + case "timezone": + self._utc_paths.add((local, "utc")) + case nodes.ImportFrom(modname="homeassistant.util", names=names): + # ``homeassistant.util.dt`` re-exports ``UTC`` from + # ``datetime``, so ``dt_util.UTC`` must be flagged too. + for name, alias in names: + if name == "dt": + local = alias or name + self._utc_paths.add((local, "UTC")) + case nodes.ImportFrom(modname="homeassistant.util.dt", names=names): + for name, alias in names: + if name == "UTC": + self._utc_paths.add((alias or name,)) + case nodes.Import(names=names): + for name, alias in names: + match name: + case "datetime": + local = alias or name + self._datetime_class_paths.add((local, "datetime")) + self._utc_paths.add((local, "UTC")) + self._utc_paths.add((local, "timezone", "utc")) + case "homeassistant.util.dt" if alias: + self._utc_paths.add((alias, "UTC")) + + def visit_call(self, node: nodes.Call) -> None: + """Check for ``datetime.now()`` calls matching the configured case.""" + if not self._enabled: + return + + match node: + case nodes.Call( + func=nodes.Attribute(attrname="now", expr=expr), + args=[arg], + keywords=[], + ): + pass + case nodes.Call( + func=nodes.Attribute(attrname="now", expr=expr), + args=[], + keywords=[nodes.Keyword(arg="tz", value=arg)], + ): + pass + case _: + return + + if attribute_path(expr) not in self._datetime_class_paths: + return + is_utc = attribute_path(arg) in self._utc_paths or is_zoneinfo_utc(arg) + if is_utc is not self.flags_utc: + return + + self.add_message(self.message, node=node) diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 46036eb9b159..9589051d871c 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -537,7 +537,9 @@ async def test_add_event_date_time( mock_events_list({}) assert await component_setup() - start_datetime = datetime.datetime.now(tz=zoneinfo.ZoneInfo("America/Regina")) + start_datetime = datetime.datetime.now( # pylint: disable=home-assistant-enforce-now + tz=zoneinfo.ZoneInfo("America/Regina") + ) delta = datetime.timedelta(days=3, hours=3) end_datetime = start_datetime + delta @@ -600,7 +602,9 @@ async def test_unsupported_create_event( mock_events_list({}) assert await component_setup() - start_datetime = datetime.datetime.now(tz=zoneinfo.ZoneInfo("America/Regina")) + start_datetime = datetime.datetime.now( # pylint: disable=home-assistant-enforce-now + tz=zoneinfo.ZoneInfo("America/Regina") + ) delta = datetime.timedelta(days=3, hours=3) end_datetime = start_datetime + delta entity_id = "calendar.backyard_light" diff --git a/tests/pylint/conftest.py b/tests/pylint/conftest.py index 28d5967dbada..9d81b08308a7 100644 --- a/tests/pylint/conftest.py +++ b/tests/pylint/conftest.py @@ -17,6 +17,7 @@ from pylint_home_assistant.checkers.greek_micro_char import ( HassEnforceGreekMicroCharChecker, ) from pylint_home_assistant.checkers.imports import HassImportsFormatChecker +from pylint_home_assistant.checkers.now import HassEnforceNowChecker from pylint_home_assistant.checkers.runtime_data import HassEnforceRuntimeDataChecker from pylint_home_assistant.checkers.sorted_platforms import ( HassEnforceSortedPlatformsChecker, @@ -131,6 +132,14 @@ def enforce_greek_micro_char_checker_fixture(linter: UnittestLinter) -> BaseChec return enforce_greek_micro_char_checker +@pytest.fixture(name="enforce_now_checker") +def enforce_now_checker_fixture(linter: UnittestLinter) -> BaseChecker: + """Fixture to provide a now checker.""" + enforce_now_checker = HassEnforceNowChecker(linter) + enforce_now_checker.module = "homeassistant.components.pylint_test" + return enforce_now_checker + + @pytest.fixture(name="enforce_utcnow_checker") def enforce_utcnow_checker_fixture(linter: UnittestLinter) -> BaseChecker: """Fixture to provide a utcnow checker.""" diff --git a/tests/pylint/test_now.py b/tests/pylint/test_now.py new file mode 100644 index 000000000000..20a4d874cec3 --- /dev/null +++ b/tests/pylint/test_now.py @@ -0,0 +1,238 @@ +"""Tests for the home-assistant-enforce-now checker.""" + +import astroid +from pylint.checkers import BaseChecker +from pylint.testutils.unittest_linter import UnittestLinter +from pylint.utils.ast_walker import ASTWalker +import pytest + +from . import assert_no_messages + + +@pytest.mark.parametrize( + "code", + [ + pytest.param( + """ + from homeassistant.util import dt as dt_util + + now = dt_util.now() + """, + id="now_helper", + ), + pytest.param( + # Calling ``datetime.now()`` with no argument returns naive local + # time, which is not equivalent to ``dt_util.now()``. + """ + from datetime import datetime + + now = datetime.now() + """, + id="now_no_args", + ), + pytest.param( + # The UTC case is handled by the ``enforce-utcnow`` checker. + """ + from datetime import datetime, UTC + + now = datetime.now(UTC) + """, + id="now_with_utc", + ), + pytest.param( + """ + from datetime import datetime, timezone + + now = datetime.now(timezone.utc) + """, + id="now_with_timezone_utc", + ), + pytest.param( + """ + import datetime + + now = datetime.datetime.now(datetime.UTC) + """, + id="qualified_now_with_utc", + ), + pytest.param( + """ + from datetime import datetime + from zoneinfo import ZoneInfo + + now = datetime.now(ZoneInfo("UTC")) + """, + id="now_with_zoneinfo_utc", + ), + pytest.param( + """ + from datetime import datetime, UTC + + now = datetime.now(tz=UTC) + """, + id="kwarg_now_with_utc", + ), + pytest.param( + # ``UTC`` re-exported from ``homeassistant.util.dt`` is still the + # UTC case, handled by the ``enforce-utcnow`` checker. + """ + import datetime + + from homeassistant.util import dt as dt_util + + now = datetime.datetime.now(dt_util.UTC) + """, + id="dt_util_utc", + ), + pytest.param( + """ + import datetime + + from homeassistant.util import dt as dt_util + + now = datetime.datetime.now(tz=dt_util.UTC) + """, + id="kwarg_dt_util_utc", + ), + pytest.param( + """ + from datetime import datetime + from homeassistant.util.dt import UTC + + now = datetime.now(UTC) + """, + id="from_util_dt_import_utc", + ), + pytest.param( + """ + import datetime + + import homeassistant.util.dt as dt_util + + now = datetime.datetime.now(dt_util.UTC) + """, + id="import_util_dt_as_dt_util_utc", + ), + pytest.param( + # Calling ``.now`` on something that is not ``datetime.datetime`` + # must not be flagged. + """ + from zoneinfo import ZoneInfo + + class Counter: + def now(self, tz): + return 0 + + Counter().now(ZoneInfo("Europe/Stockholm")) + """, + id="other_now_method", + ), + ], +) +def test_enforce_now_good( + linter: UnittestLinter, + enforce_now_checker: BaseChecker, + code: str, +) -> None: + """Good test cases -- no message expected.""" + root_node = astroid.parse(code, "homeassistant.components.pylint_test") + walker = ASTWalker(linter) + walker.add_checker(enforce_now_checker) + + with assert_no_messages(linter): + walker.walk(root_node) + + +@pytest.mark.parametrize( + "code", + [ + pytest.param( + """ + from datetime import datetime + from zoneinfo import ZoneInfo + + now = datetime.now(ZoneInfo("Europe/Stockholm")) + """, + id="from_import_zoneinfo", + ), + pytest.param( + """ + from datetime import datetime + from zoneinfo import ZoneInfo + + now = datetime.now(tz=ZoneInfo("Europe/Stockholm")) + """, + id="kwarg_zoneinfo", + ), + pytest.param( + """ + import datetime + import zoneinfo + + now = datetime.datetime.now(zoneinfo.ZoneInfo("Europe/Stockholm")) + """, + id="qualified_datetime", + ), + pytest.param( + """ + import datetime as dt + from zoneinfo import ZoneInfo + + now = dt.datetime.now(ZoneInfo("Europe/Stockholm")) + """, + id="aliased_datetime", + ), + pytest.param( + # A time zone passed in as a variable is still flagged. + """ + from datetime import datetime, tzinfo + + def get_now(time_zone: tzinfo) -> datetime: + return datetime.now(time_zone) + """, + id="variable_tz", + ), + pytest.param( + """ + from datetime import datetime, tzinfo + + def get_now(time_zone: tzinfo) -> datetime: + return datetime.now(tz=time_zone) + """, + id="kwarg_variable_tz", + ), + ], +) +def test_enforce_now_bad( + linter: UnittestLinter, + enforce_now_checker: BaseChecker, + code: str, +) -> None: + """Bad test cases -- one message expected per call.""" + root_node = astroid.parse(code, "homeassistant.components.pylint_test") + walker = ASTWalker(linter) + walker.add_checker(enforce_now_checker) + + walker.walk(root_node) + messages = linter.release_messages() + assert len(messages) == 1 + assert messages[0].msg_id == "home-assistant-enforce-now" + + +def test_enforce_now_skips_util_dt( + linter: UnittestLinter, + enforce_now_checker: BaseChecker, +) -> None: + """``homeassistant.util.dt`` defines ``now`` itself, so it is skipped.""" + code = """ + from datetime import datetime + from zoneinfo import ZoneInfo + + now = datetime.now(ZoneInfo("Europe/Stockholm")) + """ + root_node = astroid.parse(code, "homeassistant.util.dt") + walker = ASTWalker(linter) + walker.add_checker(enforce_now_checker) + + with assert_no_messages(linter): + walker.walk(root_node) From 158a8b8c69e76ccfdf8004fea3cc464cd5f98987 Mon Sep 17 00:00:00 2001 From: Manu Date: Wed, 10 Jun 2026 12:00:28 +0200 Subject: [PATCH 067/404] Add OptionsFlow to SMTP integration (#173386) --- homeassistant/components/smtp/__init__.py | 1 + homeassistant/components/smtp/config_flow.py | 49 ++++++++++++++++++++ homeassistant/components/smtp/strings.json | 12 +++++ tests/components/smtp/conftest.py | 4 ++ tests/components/smtp/test_config_flow.py | 33 ++++++++++++- tests/components/smtp/test_init.py | 4 +- 6 files changed, 100 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smtp/__init__.py b/homeassistant/components/smtp/__init__.py index 4bcd8462de00..c2da7608bb3c 100644 --- a/homeassistant/components/smtp/__init__.py +++ b/homeassistant/components/smtp/__init__.py @@ -22,6 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: CONF_RECIPIENT: [ subentry.unique_id for subentry in entry.subentries.values() ], + **entry.options, }, {}, ) diff --git a/homeassistant/components/smtp/config_flow.py b/homeassistant/components/smtp/config_flow.py index 2b9a982e6023..b9da23602d80 100644 --- a/homeassistant/components/smtp/config_flow.py +++ b/homeassistant/components/smtp/config_flow.py @@ -16,6 +16,7 @@ from homeassistant.config_entries import ( ConfigSubentryData, ConfigSubentryFlow, FlowType, + OptionsFlow, SubentryFlowContext, SubentryFlowResult, ) @@ -25,12 +26,17 @@ from homeassistant.const import ( CONF_PORT, CONF_RECIPIENT, CONF_SENDER, + CONF_TIMEOUT, CONF_USERNAME, CONF_VERIFY_SSL, + UnitOfTime, ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -90,6 +96,23 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.All( + NumberSelector( + NumberSelectorConfig( + min=1, + max=1800, + step=1, + unit_of_measurement=UnitOfTime.SECONDS, + mode=NumberSelectorMode.BOX, + ) + ), + vol.Coerce(int), + ) + } +) + class MailConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for SMTP.""" @@ -102,6 +125,12 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): """Return subentries supported by this integration.""" return {SUBENTRY_TYPE_RECIPIENT: RecipientSubentryFlowHandler} + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: + """Get the options flow for this handler.""" + return OptionsFlowHandler() + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -144,6 +173,7 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult: """Import config from yaml.""" + options = {CONF_TIMEOUT: import_info.pop(CONF_TIMEOUT, DEFAULT_TIMEOUT)} self._async_abort_entries_match(import_info) errors = await self.hass.async_add_executor_job(validate_input, import_info) @@ -156,6 +186,7 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=title, data=import_info, + options=options, subentries=[ ConfigSubentryData( subentry_type=SUBENTRY_TYPE_RECIPIENT, @@ -238,3 +269,21 @@ class RecipientSubentryFlowHandler(ConfigSubentryFlow): } ), ) + + +class OptionsFlowHandler(OptionsFlow): + """Handle options flow.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + OPTIONS_SCHEMA, self.config_entry.options + ), + ) diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index 9f6e0eb6fa65..849e78ce457a 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -70,6 +70,18 @@ "title": "Failed to import SMTP YAML configuration" } }, + "options": { + "step": { + "init": { + "data": { + "timeout": "Connection timeout" + }, + "data_description": { + "timeout": "Maximum time to wait for a response from the SMTP server before the connection attempt is aborted." + } + } + } + }, "selector": { "encryption": { "options": { diff --git a/tests/components/smtp/conftest.py b/tests/components/smtp/conftest.py index 0238bc34f9ca..3032e101a548 100644 --- a/tests/components/smtp/conftest.py +++ b/tests/components/smtp/conftest.py @@ -17,6 +17,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_SENDER, + CONF_TIMEOUT, CONF_USERNAME, CONF_VERIFY_SSL, ) @@ -74,6 +75,9 @@ def mock_config_entry() -> MockConfigEntry: CONF_PASSWORD: "test-password", CONF_VERIFY_SSL: True, }, + options={ + CONF_TIMEOUT: 5, + }, entry_id="123456789", subentries_data=[ ConfigSubentryData( diff --git a/tests/components/smtp/test_config_flow.py b/tests/components/smtp/test_config_flow.py index b4fab424276e..ca82c0d90514 100644 --- a/tests/components/smtp/test_config_flow.py +++ b/tests/components/smtp/test_config_flow.py @@ -14,13 +14,14 @@ from homeassistant.components.smtp.const import ( DOMAIN, SUBENTRY_TYPE_RECIPIENT, ) -from homeassistant.config_entries import SOURCE_USER, FlowType +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState, FlowType from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_RECIPIENT, CONF_SENDER, + CONF_TIMEOUT, CONF_USERNAME, CONF_VERIFY_SSL, ) @@ -221,3 +222,33 @@ async def test_form_recipient_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_options_flow( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test options flow.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_TIMEOUT: 10, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.options == { + CONF_TIMEOUT: 10, + } diff --git a/tests/components/smtp/test_init.py b/tests/components/smtp/test_init.py index 2fd19bdd40a9..b077c36dbf9b 100644 --- a/tests/components/smtp/test_init.py +++ b/tests/components/smtp/test_init.py @@ -73,6 +73,7 @@ async def test_import( CONF_PASSWORD: "test-password", CONF_VERIFY_SSL: True, CONF_RECIPIENT: "recipient@example.com", + CONF_TIMEOUT: 10, } ] }, @@ -98,9 +99,9 @@ async def test_import( CONF_PASSWORD: "test-password", CONF_VERIFY_SSL: True, CONF_RECIPIENT: ["recipient@example.com"], - CONF_TIMEOUT: 5, CONF_DEBUG: False, } + assert entries[0].options == {CONF_TIMEOUT: 10} assert list(entries[0].subentries.values())[0].unique_id == "recipient@example.com" @@ -133,7 +134,6 @@ async def test_import_already_configured( CONF_VERIFY_SSL: True, CONF_RECIPIENT: ["recipient@example.com"], CONF_DEBUG: False, - CONF_TIMEOUT: 5, }, entry_id="123456789", ) From ae23d0e3e739277429bd850342c273858a02ed79 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 10 Jun 2026 12:55:39 +0200 Subject: [PATCH 068/404] Add user entities to Github (#173405) --- homeassistant/components/github/__init__.py | 26 +- .../components/github/coordinator.py | 49 +- .../components/github/diagnostics.py | 2 +- homeassistant/components/github/icons.json | 12 + homeassistant/components/github/sensor.py | 90 +- homeassistant/components/github/strings.json | 16 + tests/components/github/conftest.py | 6 + tests/components/github/fixtures/user.json | 20 + .../github/snapshots/test_sensor.ambr | 919 ++++++++++++++++++ tests/components/github/test_init.py | 6 +- tests/components/github/test_sensor.py | 23 +- 11 files changed, 1154 insertions(+), 15 deletions(-) create mode 100644 tests/components/github/fixtures/user.json create mode 100644 tests/components/github/snapshots/test_sensor.ambr diff --git a/homeassistant/components/github/__init__.py b/homeassistant/components/github/__init__.py index 1309f4b58d62..4d2dc968eb9c 100644 --- a/homeassistant/components/github/__init__.py +++ b/homeassistant/components/github/__init__.py @@ -14,7 +14,12 @@ from homeassistant.helpers.aiohttp_client import ( ) from .const import CONF_REPOSITORIES, CONF_REPOSITORY, DOMAIN, SUBENTRY_TYPE_REPOSITORY -from .coordinator import GithubConfigEntry, GitHubDataUpdateCoordinator +from .coordinator import ( + GithubConfigEntry, + GitHubDataUpdateCoordinator, + GitHubRuntimeData, + GitHubUserDataUpdateCoordinator, +) PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -27,7 +32,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo client_name=SERVER_SOFTWARE, ) - entry.runtime_data = {} + user_coordinator = GitHubUserDataUpdateCoordinator( + hass=hass, + config_entry=entry, + client=client, + ) + await user_coordinator.async_config_entry_first_refresh() + + repositories: dict[str, GitHubDataUpdateCoordinator] = {} for repository_subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_REPOSITORY): repository = repository_subentry.data[CONF_REPOSITORY] coordinator = GitHubDataUpdateCoordinator( @@ -42,7 +54,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo if not entry.pref_disable_polling: await coordinator.subscribe() - entry.runtime_data[repository_subentry.subentry_id] = coordinator + repositories[repository_subentry.subentry_id] = coordinator + + entry.runtime_data = GitHubRuntimeData( + user_coordinator=user_coordinator, + repositories=repositories, + ) entry.async_on_unload(entry.add_update_listener(async_update_entry)) @@ -57,8 +74,7 @@ async def async_update_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> N async def async_unload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bool: """Unload a config entry.""" - repositories = entry.runtime_data - for coordinator in repositories.values(): + for coordinator in entry.runtime_data.repositories.values(): coordinator.unsubscribe() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/github/coordinator.py b/homeassistant/components/github/coordinator.py index fbb5b20384f0..56d17d826de5 100644 --- a/homeassistant/components/github/coordinator.py +++ b/homeassistant/components/github/coordinator.py @@ -1,9 +1,11 @@ """Custom data update coordinator for the GitHub integration.""" +from dataclasses import dataclass from typing import Any from aiogithubapi import ( GitHubAPI, + GitHubAuthenticatedUserModel, GitHubConnectionException, GitHubEventModel, GitHubException, @@ -103,7 +105,52 @@ query ($owner: String!, $repository: String!) { } """ -type GithubConfigEntry = ConfigEntry[dict[str, GitHubDataUpdateCoordinator]] +type GithubConfigEntry = ConfigEntry[GitHubRuntimeData] + + +@dataclass +class GitHubRuntimeData: + """Runtime data for the GitHub integration.""" + + user_coordinator: GitHubUserDataUpdateCoordinator + repositories: dict[str, GitHubDataUpdateCoordinator] + + +class GitHubUserDataUpdateCoordinator( + DataUpdateCoordinator[GitHubAuthenticatedUserModel] +): + """Data update coordinator for the authenticated GitHub user.""" + + config_entry: GithubConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: GithubConfigEntry, + client: GitHubAPI, + ) -> None: + """Initialize GitHub user data update coordinator.""" + self._client = client + + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name="user", + update_interval=FALLBACK_UPDATE_INTERVAL, + ) + + async def _async_update_data(self) -> GitHubAuthenticatedUserModel: + """Update data.""" + try: + response = await self._client.user.get() + except (GitHubConnectionException, GitHubRatelimitException) as exception: + raise UpdateFailed(exception) from exception + except GitHubException as exception: + LOGGER.exception(exception) + raise UpdateFailed(exception) from exception + + return response.data class GitHubDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): diff --git a/homeassistant/components/github/diagnostics.py b/homeassistant/components/github/diagnostics.py index 67a5fe233ce9..eae9055c2618 100644 --- a/homeassistant/components/github/diagnostics.py +++ b/homeassistant/components/github/diagnostics.py @@ -33,7 +33,7 @@ async def async_get_config_entry_diagnostics( else: data["rate_limit"] = rate_limit_response.data.as_dict - repositories = config_entry.runtime_data + repositories = config_entry.runtime_data.repositories data["repositories"] = {} for coordinator in repositories.values(): diff --git a/homeassistant/components/github/icons.json b/homeassistant/components/github/icons.json index 90f15bb550ac..5e684ab19d2b 100644 --- a/homeassistant/components/github/icons.json +++ b/homeassistant/components/github/icons.json @@ -4,6 +4,12 @@ "discussions_count": { "default": "mdi:forum" }, + "followers": { + "default": "mdi:account-multiple" + }, + "following": { + "default": "mdi:account-multiple-outline" + }, "forks_count": { "default": "mdi:source-fork" }, @@ -31,6 +37,12 @@ "merged_pulls_count": { "default": "mdi:source-merge" }, + "public_gists": { + "default": "mdi:code-json" + }, + "public_repos": { + "default": "mdi:source-repository" + }, "pulls_count": { "default": "mdi:source-pull" }, diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index 5cc33a9636c2..a67b7ef287ae 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -4,6 +4,8 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Any +from aiogithubapi import GitHubAuthenticatedUserModel + from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, @@ -17,7 +19,11 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import GithubConfigEntry, GitHubDataUpdateCoordinator +from .coordinator import ( + GithubConfigEntry, + GitHubDataUpdateCoordinator, + GitHubUserDataUpdateCoordinator, +) @dataclass(frozen=True, kw_only=True) @@ -141,14 +147,58 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = ( ) +@dataclass(frozen=True, kw_only=True) +class GitHubUserSensorEntityDescription(SensorEntityDescription): + """Describes GitHub user sensor entity.""" + + value_fn: Callable[[GitHubAuthenticatedUserModel], StateType] + + +USER_SENSOR_DESCRIPTIONS: tuple[GitHubUserSensorEntityDescription, ...] = ( + GitHubUserSensorEntityDescription( + key="followers", + translation_key="followers", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda data: data.followers, + ), + GitHubUserSensorEntityDescription( + key="following", + translation_key="following", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda data: data.following, + ), + GitHubUserSensorEntityDescription( + key="public_gists", + translation_key="public_gists", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda data: data.public_gists, + ), + GitHubUserSensorEntityDescription( + key="public_repos", + translation_key="public_repos", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda data: data.public_repos, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: GithubConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up GitHub sensor based on a config entry.""" - repositories = entry.runtime_data - for subentry_id, coordinator in repositories.items(): + user_coordinator = entry.runtime_data.user_coordinator + async_add_entities( + GitHubUserSensorEntity(user_coordinator, description) + for description in USER_SENSOR_DESCRIPTIONS + ) + + for subentry_id, coordinator in entry.runtime_data.repositories.items(): async_add_entities( ( GitHubSensorEntity(coordinator, description) @@ -203,3 +253,37 @@ class GitHubSensorEntity(CoordinatorEntity[GitHubDataUpdateCoordinator], SensorE def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the extra state attributes.""" return self.entity_description.attr_fn(self.coordinator.data) + + +class GitHubUserSensorEntity( + CoordinatorEntity[GitHubUserDataUpdateCoordinator], SensorEntity +): + """Defines a GitHub user sensor entity.""" + + _attr_has_entity_name = True + + entity_description: GitHubUserSensorEntityDescription + + def __init__( + self, + coordinator: GitHubUserDataUpdateCoordinator, + entity_description: GitHubUserSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator=coordinator) + + self.entity_description = entity_description + self._attr_unique_id = f"{coordinator.data.id}_{entity_description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(coordinator.data.id))}, + name=coordinator.data.login, + manufacturer="GitHub", + configuration_url=f"https://github.com/{coordinator.data.login}", + entry_type=DeviceEntryType.SERVICE, + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/github/strings.json b/homeassistant/components/github/strings.json index 7c21e979441a..868f90f46a07 100644 --- a/homeassistant/components/github/strings.json +++ b/homeassistant/components/github/strings.json @@ -36,6 +36,14 @@ "name": "Discussions", "unit_of_measurement": "discussions" }, + "followers": { + "name": "Followers", + "unit_of_measurement": "followers" + }, + "following": { + "name": "Following", + "unit_of_measurement": "users" + }, "forks_count": { "name": "Forks", "unit_of_measurement": "forks" @@ -66,6 +74,14 @@ "name": "Merged pull requests", "unit_of_measurement": "pull requests" }, + "public_gists": { + "name": "Public gists", + "unit_of_measurement": "gists" + }, + "public_repos": { + "name": "Public repositories", + "unit_of_measurement": "repositories" + }, "pulls_count": { "name": "Pull requests", "unit_of_measurement": "pull requests" diff --git a/tests/components/github/conftest.py b/tests/components/github/conftest.py index ed50efd56451..19b26ae6461e 100644 --- a/tests/components/github/conftest.py +++ b/tests/components/github/conftest.py @@ -5,6 +5,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from aiogithubapi import ( + GitHubAuthenticatedUserModel, GitHubLoginDeviceModel, GitHubLoginOauthModel, GitHubRateLimitModel, @@ -155,5 +156,10 @@ def github_client(hass: HomeAssistant) -> Generator[AsyncMock]: graphql_mock = AsyncMock() graphql_mock.data = load_json_object_fixture("graphql.json", DOMAIN) client.graphql.return_value = graphql_mock + user_response_mock = MagicMock() + user_response_mock.data = GitHubAuthenticatedUserModel( + load_json_object_fixture("user.json", DOMAIN) + ) + client.user.get = AsyncMock(return_value=user_response_mock) client.repos.events.subscribe = AsyncMock() yield client diff --git a/tests/components/github/fixtures/user.json b/tests/components/github/fixtures/user.json new file mode 100644 index 000000000000..aef913337193 --- /dev/null +++ b/tests/components/github/fixtures/user.json @@ -0,0 +1,20 @@ +{ + "login": "octocat", + "id": 583231, + "avatar_url": "https://avatars.githubusercontent.com/u/583231?v=4", + "html_url": "https://github.com/octocat", + "type": "User", + "name": "The Octocat", + "company": "@github", + "blog": "https://github.blog", + "location": "San Francisco", + "email": null, + "hireable": null, + "bio": null, + "public_repos": 8, + "public_gists": 8, + "followers": 17265, + "following": 9, + "created_at": "2011-01-25T18:44:36Z", + "updated_at": "2024-01-01T00:00:00Z" +} diff --git a/tests/components/github/snapshots/test_sensor.ambr b/tests/components/github/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..5e46caaa2d9a --- /dev/null +++ b/tests/components/github/snapshots/test_sensor.ambr @@ -0,0 +1,919 @@ +# serializer version: 1 +# name: test_all_entities[sensor.octocat_followers-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.octocat_followers', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Followers', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Followers', + 'platform': 'github', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'followers', + 'unique_id': '583231_followers', + 'unit_of_measurement': 'followers', + }) +# --- +# name: test_all_entities[sensor.octocat_followers-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'octocat Followers', + 'state_class': , + 'unit_of_measurement': 'followers', + }), + 'context': , + 'entity_id': 'sensor.octocat_followers', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17265', + }) +# --- +# name: test_all_entities[sensor.octocat_following-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.octocat_following', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Following', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Following', + 'platform': 'github', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'following', + 'unique_id': '583231_following', + 'unit_of_measurement': 'users', + }) +# --- +# name: test_all_entities[sensor.octocat_following-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'octocat Following', + 'state_class': , + 'unit_of_measurement': 'users', + }), + 'context': , + 'entity_id': 'sensor.octocat_following', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9', + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_discussions-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.octocat_hello_world_discussions', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Discussions', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Discussions', + 'platform': 'github', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'discussions_count', + 'unique_id': '1296269_discussions_count', + 'unit_of_measurement': 'discussions', + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_discussions-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by the GitHub API', + 'friendly_name': 'octocat/Hello-World Discussions', + 'state_class': , + 'unit_of_measurement': 'discussions', + }), + 'context': , + 'entity_id': 'sensor.octocat_hello_world_discussions', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_forks-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.octocat_hello_world_forks', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Forks', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Forks', + 'platform': 'github', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'forks_count', + 'unique_id': '1296269_forks_count', + 'unit_of_measurement': 'forks', + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_forks-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by the GitHub API', + 'friendly_name': 'octocat/Hello-World Forks', + 'state_class': , + 'unit_of_measurement': 'forks', + }), + 'context': , + 'entity_id': 'sensor.octocat_hello_world_forks', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9', + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_issues-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.octocat_hello_world_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Issues', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Issues', + 'platform': 'github', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'issues_count', + 'unique_id': '1296269_issues_count', + 'unit_of_measurement': 'issues', + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by the GitHub API', + 'friendly_name': 'octocat/Hello-World Issues', + 'state_class': , + 'unit_of_measurement': 'issues', + }), + 'context': , + 'entity_id': 'sensor.octocat_hello_world_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_latest_commit-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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.octocat_hello_world_latest_commit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Latest commit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latest commit', + 'platform': 'github', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'latest_commit', + 'unique_id': '1296269_latest_commit', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_latest_commit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by the GitHub API', + 'friendly_name': 'octocat/Hello-World Latest commit', + 'sha': '6dcb09b5b57875f334f61aebed695e2e4193db5e', + 'url': 'https://github.com/octocat/Hello-World/commit/6dcb09b5b57875f334f61aebed695e2e4193db5e', + }), + 'context': , + 'entity_id': 'sensor.octocat_hello_world_latest_commit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Fix all the bugs', + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_latest_discussion-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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.octocat_hello_world_latest_discussion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Latest discussion', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latest discussion', + 'platform': 'github', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'latest_discussion', + 'unique_id': '1296269_latest_discussion', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_latest_discussion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by the GitHub API', + 'friendly_name': 'octocat/Hello-World Latest discussion', + 'number': 1347, + 'url': 'https://github.com/octocat/Hello-World/discussions/1347', + }), + 'context': , + 'entity_id': 'sensor.octocat_hello_world_latest_discussion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'First discussion', + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_latest_issue-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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.octocat_hello_world_latest_issue', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Latest issue', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latest issue', + 'platform': 'github', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'latest_issue', + 'unique_id': '1296269_latest_issue', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_latest_issue-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by the GitHub API', + 'friendly_name': 'octocat/Hello-World Latest issue', + 'number': 1347, + 'url': 'https://github.com/octocat/Hello-World/issues/1347', + }), + 'context': , + 'entity_id': 'sensor.octocat_hello_world_latest_issue', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Found a bug', + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_latest_pull_request-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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.octocat_hello_world_latest_pull_request', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Latest pull request', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latest pull request', + 'platform': 'github', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'latest_pull_request', + 'unique_id': '1296269_latest_pull_request', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_latest_pull_request-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by the GitHub API', + 'friendly_name': 'octocat/Hello-World Latest pull request', + 'number': 1347, + 'url': 'https://github.com/octocat/Hello-World/pull/1347', + }), + 'context': , + 'entity_id': 'sensor.octocat_hello_world_latest_pull_request', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Amazing new feature', + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_latest_release-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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.octocat_hello_world_latest_release', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Latest release', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latest release', + 'platform': 'github', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'latest_release', + 'unique_id': '1296269_latest_release', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_latest_release-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by the GitHub API', + 'friendly_name': 'octocat/Hello-World Latest release', + 'tag': 'v1.0.0', + 'url': 'https://github.com/octocat/Hello-World/releases/v1.0.0', + }), + 'context': , + 'entity_id': 'sensor.octocat_hello_world_latest_release', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'v1.0.0', + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_latest_tag-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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.octocat_hello_world_latest_tag', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Latest tag', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latest tag', + 'platform': 'github', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'latest_tag', + 'unique_id': '1296269_latest_tag', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_latest_tag-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by the GitHub API', + 'friendly_name': 'octocat/Hello-World Latest tag', + 'url': 'https://github.com/octocat/Hello-World/commit/6dcb09b5b57875f334f61aebed695e2e4193db5e', + }), + 'context': , + 'entity_id': 'sensor.octocat_hello_world_latest_tag', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'v1.0.0', + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_merged_pull_requests-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.octocat_hello_world_merged_pull_requests', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Merged pull requests', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Merged pull requests', + 'platform': 'github', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'merged_pulls_count', + 'unique_id': '1296269_merged_pulls_count', + 'unit_of_measurement': 'pull requests', + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_merged_pull_requests-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by the GitHub API', + 'friendly_name': 'octocat/Hello-World Merged pull requests', + 'state_class': , + 'unit_of_measurement': 'pull requests', + }), + 'context': , + 'entity_id': 'sensor.octocat_hello_world_merged_pull_requests', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_pull_requests-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.octocat_hello_world_pull_requests', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pull requests', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pull requests', + 'platform': 'github', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pulls_count', + 'unique_id': '1296269_pulls_count', + 'unit_of_measurement': 'pull requests', + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_pull_requests-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by the GitHub API', + 'friendly_name': 'octocat/Hello-World Pull requests', + 'state_class': , + 'unit_of_measurement': 'pull requests', + }), + 'context': , + 'entity_id': 'sensor.octocat_hello_world_pull_requests', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_stars-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.octocat_hello_world_stars', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Stars', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stars', + 'platform': 'github', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stargazers_count', + 'unique_id': '1296269_stargazers_count', + 'unit_of_measurement': 'stars', + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_stars-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by the GitHub API', + 'friendly_name': 'octocat/Hello-World Stars', + 'state_class': , + 'unit_of_measurement': 'stars', + }), + 'context': , + 'entity_id': 'sensor.octocat_hello_world_stars', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9', + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_watchers-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.octocat_hello_world_watchers', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Watchers', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Watchers', + 'platform': 'github', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'subscribers_count', + 'unique_id': '1296269_subscribers_count', + 'unit_of_measurement': 'watchers', + }) +# --- +# name: test_all_entities[sensor.octocat_hello_world_watchers-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by the GitHub API', + 'friendly_name': 'octocat/Hello-World Watchers', + 'state_class': , + 'unit_of_measurement': 'watchers', + }), + 'context': , + 'entity_id': 'sensor.octocat_hello_world_watchers', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9', + }) +# --- +# name: test_all_entities[sensor.octocat_public_gists-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.octocat_public_gists', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Public gists', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Public gists', + 'platform': 'github', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'public_gists', + 'unique_id': '583231_public_gists', + 'unit_of_measurement': 'gists', + }) +# --- +# name: test_all_entities[sensor.octocat_public_gists-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'octocat Public gists', + 'state_class': , + 'unit_of_measurement': 'gists', + }), + 'context': , + 'entity_id': 'sensor.octocat_public_gists', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8', + }) +# --- +# name: test_all_entities[sensor.octocat_public_repositories-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.octocat_public_repositories', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Public repositories', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Public repositories', + 'platform': 'github', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'public_repos', + 'unique_id': '583231_public_repos', + 'unit_of_measurement': 'repositories', + }) +# --- +# name: test_all_entities[sensor.octocat_public_repositories-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'octocat Public repositories', + 'state_class': , + 'unit_of_measurement': 'repositories', + }), + 'context': , + 'entity_id': 'sensor.octocat_public_repositories', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8', + }) +# --- diff --git a/tests/components/github/test_init.py b/tests/components/github/test_init.py index 53f1aa78b94e..cfbea8f171c2 100644 --- a/tests/components/github/test_init.py +++ b/tests/components/github/test_init.py @@ -28,7 +28,8 @@ async def test_device_registry_cleanup( config_entry_id=mock_config_entry.entry_id, ) - assert len(devices) == 1 + # One device for the authenticated user, one for the configured repository + assert len(devices) == 2 hass.config_entries.async_remove_subentry( mock_config_entry, list(mock_config_entry.subentries)[0] @@ -40,7 +41,8 @@ async def test_device_registry_cleanup( config_entry_id=mock_config_entry.entry_id, ) - assert len(devices) == 0 + # Only the user device remains after removing the repository subentry + assert len(devices) == 1 async def test_subscription_setup( diff --git a/tests/components/github/test_sensor.py b/tests/components/github/test_sensor.py index fae93a68d488..466b545b6e4c 100644 --- a/tests/components/github/test_sensor.py +++ b/tests/components/github/test_sensor.py @@ -1,20 +1,37 @@ """Test GitHub sensor.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.github.const import FALLBACK_UPDATE_INTERVAL -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform TEST_SENSOR_ENTITY = "sensor.octocat_hello_world_latest_release" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + github_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.github.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + async def test_sensor_updates_with_empty_release_array( hass: HomeAssistant, github_client: AsyncMock, From 742a6282f70e58a0ab1224af66f7371176257ad7 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Wed, 10 Jun 2026 13:57:36 +0300 Subject: [PATCH 069/404] Bump Anthropic to 0.108.0 (#173430) --- homeassistant/components/anthropic/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json index 7009805e9bed..a1d748751483 100644 --- a/homeassistant/components/anthropic/manifest.json +++ b/homeassistant/components/anthropic/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["anthropic==0.96.0"] + "requirements": ["anthropic==0.108.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6ff47ada1d19..65fb8b271f3b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -519,7 +519,7 @@ anova-wifi==0.17.0 anthemav==1.4.1 # homeassistant.components.anthropic -anthropic==0.96.0 +anthropic==0.108.0 # homeassistant.components.mcp_server anyio==4.13.0 From 1684ea7870340b53e70c25deea8e26b33a787091 Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Wed, 10 Jun 2026 13:00:07 +0200 Subject: [PATCH 070/404] Bump homematicip to 2.13.0 (#173427) --- homeassistant/components/homematicip_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 5ef2179bb616..70ca8e8edf02 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==2.12.0"] + "requirements": ["homematicip==2.13.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 65fb8b271f3b..433455e3b638 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1281,7 +1281,7 @@ homekit-audio-proxy==1.2.1 homelink-integration-api==0.0.5 # homeassistant.components.homematicip_cloud -homematicip==2.12.0 +homematicip==2.13.0 # homeassistant.components.homevolt homevolt==0.5.0 From b1f2e80f4009fe3a3dfca61c87860a591f50cf81 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:00:15 +0200 Subject: [PATCH 071/404] Modify Bluetooth setup confirmation description for gardena_bluetooth integration (#173439) --- homeassistant/components/gardena_bluetooth/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/gardena_bluetooth/strings.json b/homeassistant/components/gardena_bluetooth/strings.json index b7a848d0680f..797197beba8b 100644 --- a/homeassistant/components/gardena_bluetooth/strings.json +++ b/homeassistant/components/gardena_bluetooth/strings.json @@ -10,7 +10,7 @@ }, "step": { "confirm": { - "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + "description": "Do you want to set up {name}?\n\nBefore you continue, make sure the device is in pairing mode." }, "user": { "data": { From 5a00de9e87a32f1cdb4af0a70ceea6b8454e0141 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 10 Jun 2026 14:41:45 +0200 Subject: [PATCH 072/404] Do not enable MQTT entities though discovery that were disabled by user (#173404) --- homeassistant/components/mqtt/entity.py | 5 +++-- tests/components/mqtt/test_mixins.py | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 7915e336b522..36f63b025345 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -1432,9 +1432,10 @@ class MqttEntity( if ( self._config[CONF_ENABLED_BY_DEFAULT] and deleted_entry - and deleted_entry.disabled_by is not None + and deleted_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION ): - # Enable previous deleted entity and enable it + # Enable previous deleted entity and enable it, + # if it was not disabled by the user recreated_entry = entity_registry.async_get_or_create( entity_platform, DOMAIN, self.unique_id ) diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index 3d55ec8ffe9a..f23481731d6a 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -626,6 +626,27 @@ async def test_registry_enable_not_enabled_by_default_entity( assert not entry.disabled assert device_registry.async_get(device_id) is not None + # Mock the entry is disabled by the user + entity_registry.async_update_entity( + "sensor.test_new", disabled_by=er.RegistryEntryDisabler.USER + ) + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get("sensor.test_new") + assert state is None + # Remove the disabled entry + entity_registry.async_remove("sensor.test_new") + await hass.async_block_till_done(wait_background_tasks=True) + entry = entity_registry.async_get("sensor.test_new") + assert entry is None + + # Repeat the re-discovery, and assert the entity remains disabled + async_fire_mqtt_message(hass, discovery_topic, config_enabled_new_entity_name) + await hass.async_block_till_done() + entry = entity_registry.async_get("sensor.test_new") + assert entry is not None + assert entry.disabled + assert device_registry.async_get(device_id) is not None + @pytest.mark.parametrize( "mqtt_config_subentries_data", From d8182508bbaff186d8e352e5a1103c4103b11942 Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Wed, 10 Jun 2026 16:50:46 +0200 Subject: [PATCH 073/404] Set Epson media player device class to projector (#172585) --- homeassistant/components/epson/media_player.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py index 07c8b3f02573..55f34296ab48 100644 --- a/homeassistant/components/epson/media_player.py +++ b/homeassistant/components/epson/media_player.py @@ -27,6 +27,7 @@ from epson_projector.const import ( ) from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, @@ -62,6 +63,7 @@ class EpsonProjectorMediaPlayer(MediaPlayerEntity): _attr_has_entity_name = True _attr_name = None + _attr_device_class = MediaPlayerDeviceClass.PROJECTOR _attr_supported_features = ( MediaPlayerEntityFeature.TURN_ON From f5b8e8ba81df6e96959ee37fc5bcdb5eeea6680c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 10 Jun 2026 17:31:23 +0200 Subject: [PATCH 074/404] Bump MQTT config flow to version 2.1 (#173094) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/mqtt/__init__.py | 31 ++++++++-------- homeassistant/components/mqtt/const.py | 13 +------ tests/components/mqtt/test_config_flow.py | 43 ++--------------------- 3 files changed, 19 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 47bdd3eee11b..ec7fe933bf2a 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -87,7 +87,6 @@ from .const import ( DEFAULT_RETAIN, DOMAIN, ENTITY_PLATFORMS, - ENTRY_OPTION_FIELDS, MQTT_CONNECTION_STATE, PROTOCOL_5, PROTOCOL_311, @@ -154,7 +153,6 @@ __all__ = [ "DEFAULT_RETAIN", "DOMAIN", "ENTITY_PLATFORMS", - "ENTRY_OPTION_FIELDS", "MQTT", "MQTT_BASE_SCHEMA", "MQTT_CONNECTION_STATE", @@ -468,27 +466,30 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Migrate the options from config entry data.""" + """Migrate the config entry to the latest version.""" _LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version) data: dict[str, Any] = dict(entry.data) options: dict[str, Any] = dict(entry.options) if entry.version == 1 and entry.minor_version < 2: - # Can be removed when the config entry is bumped to version 2.1 - # with HA Core 2026.7.0. Read support for version 2.1 is expected with 2026.1 - # From 2026.7 we will write version 2.1 - for key in ENTRY_OPTION_FIELDS: + for key in ( + CONF_DISCOVERY, + CONF_DISCOVERY_PREFIX, + "birth_message", + "will_message", + ): if key not in data: continue options[key] = data.pop(key) - # Write version 1.2 for backwards compatibility - hass.config_entries.async_update_entry( - entry, - data=data, - options=options, - version=1, - minor_version=2, - ) + + # Bump config entry to version 2.1 + hass.config_entries.async_update_entry( + entry, + data=data, + options=options, + version=2, + minor_version=1, + ) _LOGGER.debug( "Migration to version %s.%s successful", entry.version, entry.minor_version diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 268441ca85c8..a045a0e96042 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -5,7 +5,7 @@ import logging import jinja2 from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature -from homeassistant.const import CONF_DISCOVERY, CONF_PAYLOAD, Platform +from homeassistant.const import CONF_PAYLOAD, Platform from homeassistant.exceptions import TemplateError ATTR_DISCOVERY_HASH = "discovery_hash" @@ -385,17 +385,6 @@ PAYLOAD_NONE = "None" CONFIG_ENTRY_VERSION = 2 CONFIG_ENTRY_MINOR_VERSION = 1 -# Split mqtt entry data and options -# Can be removed when config entry is bumped to version 2.1 -# with HA Core 2026.7.0. Read support for version 2.1 is expected from 2026.1 -# From 2026.7 we will write version 2.1 -ENTRY_OPTION_FIELDS = ( - CONF_DISCOVERY, - CONF_DISCOVERY_PREFIX, - "birth_message", - "will_message", -) - ENTITY_PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index dce672033c0c..d0be9e4e82d2 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -2586,8 +2586,8 @@ async def test_reconfigure_no_changed_password( "expected_minor_version", ), [ - (1, 1, MOCK_ENTRY_DATA | MOCK_ENTRY_OPTIONS, {}, 1, 2), - (1, 2, MOCK_ENTRY_DATA, MOCK_ENTRY_OPTIONS, 1, 2), + (1, 1, MOCK_ENTRY_DATA | MOCK_ENTRY_OPTIONS, {}, 2, 1), + (1, 2, MOCK_ENTRY_DATA, MOCK_ENTRY_OPTIONS, 2, 1), (2, 1, MOCK_ENTRY_DATA, MOCK_ENTRY_OPTIONS, 2, 1), ], ) @@ -2623,45 +2623,6 @@ async def test_migrate_config_entry( assert config_entry.minor_version == expected_minor_version -@pytest.mark.parametrize( - ( - "version", - "minor_version", - "data", - "options", - ), - [ - (3, 1, MOCK_ENTRY_DATA, MOCK_ENTRY_OPTIONS), - ], -) -@pytest.mark.usefixtures("mock_reload_after_entry_update") -async def test_migrate_of_incompatible_config_entry( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - version: int, - minor_version: int, - data: dict[str, Any], - options: dict[str, Any], -) -> None: - """Test migrating a config entry.""" - config_entry = hass.config_entries.async_entries(DOMAIN)[0] - # Mock an incompatible config entry version - hass.config_entries.async_update_entry( - config_entry, - data=data, - options=options, - version=version, - minor_version=minor_version, - ) - await hass.async_block_till_done() - - # Try to start MQTT with incompatible config entry - with pytest.raises(AssertionError): - await mqtt_mock_entry() - - assert config_entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR - - @pytest.mark.parametrize( ( "config_subentries_data", From 130ca851f68eb002ab93e5c9494c2fd3306324ee Mon Sep 17 00:00:00 2001 From: orandasoft Date: Thu, 11 Jun 2026 00:51:57 +0900 Subject: [PATCH 075/404] Add tests for itach integration (#173421) --- tests/components/itach/__init__.py | 1 + tests/components/itach/test_remote.py | 297 ++++++++++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 tests/components/itach/__init__.py create mode 100644 tests/components/itach/test_remote.py diff --git a/tests/components/itach/__init__.py b/tests/components/itach/__init__.py new file mode 100644 index 000000000000..672753cbe621 --- /dev/null +++ b/tests/components/itach/__init__.py @@ -0,0 +1 @@ +"""Tests for the iTach integration.""" diff --git a/tests/components/itach/test_remote.py b/tests/components/itach/test_remote.py new file mode 100644 index 000000000000..0ccdceb5ac88 --- /dev/null +++ b/tests/components/itach/test_remote.py @@ -0,0 +1,297 @@ +"""Tests for the iTach remote platform.""" + +import logging +from typing import Any +from unittest.mock import MagicMock, call, patch + +import pytest + +from homeassistant.components.itach import remote +from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN +from homeassistant.const import ( + CONF_DEVICES, + CONF_HOST, + CONF_MAC, + CONF_NAME, + CONF_PORT, + DEVICE_DEFAULT_NAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +# Test helpers. + + +def _default_config(**overrides: Any) -> dict[str, Any]: + """Return a default iTach YAML platform config.""" + config: dict[str, Any] = { + CONF_HOST: "192.168.1.50", + CONF_PORT: 4998, + CONF_DEVICES: [ + { + CONF_NAME: "TV", + "connaddr": 1, + "commands": [ + { + CONF_NAME: "ON", + "data": "sendir-on", + }, + ], + } + ], + } + config.update(overrides) + return config + + +def _remote_config(**overrides: Any) -> dict[str, Any]: + """Return a Home Assistant remote config for the iTach platform.""" + return { + REMOTE_DOMAIN: [ + { + "platform": "itach", + **_default_config(**overrides), + } + ] + } + + +async def _async_setup_itach_remote( + hass: HomeAssistant, + **overrides: Any, +) -> None: + """Set up the iTach remote platform through Home Assistant.""" + assert await async_setup_component( + hass, + REMOTE_DOMAIN, + _remote_config(**overrides), + ) + await hass.async_block_till_done() + + +async def test_setup_platform_creates_entity(hass: HomeAssistant) -> None: + """Test setup creates one remote entity from YAML config.""" + mock_itach = MagicMock() + mock_itach.ready.return_value = True + + with patch( + "homeassistant.components.itach.remote.pyitachip2ir.ITachIP2IR", + return_value=mock_itach, + ): + await _async_setup_itach_remote(hass) + + state = hass.states.get("remote.tv") + + assert state is not None + assert state.state == "off" + + +async def test_setup_platform_initializes_library(hass: HomeAssistant) -> None: + """Test setup initializes the pyitachip2ir client with YAML values.""" + mock_itach = MagicMock() + mock_itach.ready.return_value = True + + with patch( + "homeassistant.components.itach.remote.pyitachip2ir.ITachIP2IR", + return_value=mock_itach, + ) as mock_itach_class: + await _async_setup_itach_remote( + hass, + **{CONF_MAC: "AA:BB:CC:DD:EE:FF"}, + ) + + mock_itach_class.assert_called_once_with( + "AA:BB:CC:DD:EE:FF", + "192.168.1.50", + 4998, + ) + + +async def test_setup_platform_ready_failure( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test setup does not create an entity when iTach is not ready.""" + mock_itach = MagicMock() + mock_itach.ready.return_value = False + + caplog.set_level(logging.ERROR) + + with patch( + "homeassistant.components.itach.remote.pyitachip2ir.ITachIP2IR", + return_value=mock_itach, + ): + await _async_setup_itach_remote(hass) + + assert hass.states.get("remote.tv") is None + mock_itach.ready.assert_called_once_with(remote.CONNECT_TIMEOUT) + mock_itach.addDevice.assert_not_called() + assert "Unable to find iTach" in caplog.text + + +async def test_setup_platform_adds_device_with_command_table( + hass: HomeAssistant, +) -> None: + """Test setup adds a device with the expected command table.""" + mock_itach = MagicMock() + mock_itach.ready.return_value = True + devices = [ + { + CONF_NAME: "TV", + "modaddr": 1, + "connaddr": 2, + "commands": [ + { + CONF_NAME: "ON", + "data": "sendir-on", + }, + { + CONF_NAME: "OFF", + "data": "sendir-off", + }, + ], + } + ] + + with patch( + "homeassistant.components.itach.remote.pyitachip2ir.ITachIP2IR", + return_value=mock_itach, + ): + await _async_setup_itach_remote(hass, **{CONF_DEVICES: devices}) + + mock_itach.addDevice.assert_called_once_with( + "TV", + 1, + 2, + "ON\nsendir-on\nOFF\nsendir-off\n", + ) + + +def test_turn_on_sends_on_command() -> None: + """Test turn_on sends the ON command.""" + mock_itach = MagicMock() + entity = remote.ITachIP2IRRemote(mock_itach, "TV", 2) + + with patch.object(entity, "schedule_update_ha_state") as mock_schedule_update: + entity.turn_on() + + assert entity.is_on is True + mock_itach.send.assert_called_once_with("TV", "ON", 2) + mock_schedule_update.assert_called_once_with() + + +def test_turn_off_sends_off_command() -> None: + """Test turn_off sends the OFF command.""" + mock_itach = MagicMock() + entity = remote.ITachIP2IRRemote(mock_itach, "TV", 2) + + with patch.object(entity, "schedule_update_ha_state") as mock_schedule_update: + entity.turn_off() + + assert entity.is_on is False + mock_itach.send.assert_called_once_with("TV", "OFF", 2) + mock_schedule_update.assert_called_once_with() + + +def test_send_command_sends_each_command() -> None: + """Test send_command sends each command.""" + mock_itach = MagicMock() + entity = remote.ITachIP2IRRemote(mock_itach, "TV", 2) + + entity.send_command(["VOLUME_UP", "VOLUME_DOWN"]) + + assert mock_itach.send.call_args_list == [ + call("TV", "VOLUME_UP", 2), + call("TV", "VOLUME_DOWN", 2), + ] + + +def test_send_command_applies_num_repeats_to_ir_count() -> None: + """Test send_command multiplies ir_count by num_repeats.""" + mock_itach = MagicMock() + entity = remote.ITachIP2IRRemote(mock_itach, "TV", 2) + + entity.send_command(["VOLUME_UP"], num_repeats=3) + + mock_itach.send.assert_called_once_with("TV", "VOLUME_UP", 6) + + +def test_update_calls_library_update() -> None: + """Test update calls the pyitachip2ir update method.""" + mock_itach = MagicMock() + entity = remote.ITachIP2IRRemote(mock_itach, "TV", 2) + + entity.update() + + mock_itach.update.assert_called_once_with() + + +async def test_setup_platform_uses_default_values(hass: HomeAssistant) -> None: + """Test setup uses default values for optional YAML fields.""" + mock_itach = MagicMock() + mock_itach.ready.return_value = True + devices = [ + { + "connaddr": 2, + "commands": [ + { + CONF_NAME: "ON", + "data": "sendir-on", + }, + ], + } + ] + + with patch( + "homeassistant.components.itach.remote.pyitachip2ir.ITachIP2IR", + return_value=mock_itach, + ) as mock_itach_class: + await _async_setup_itach_remote(hass, **{CONF_DEVICES: devices}) + + states = hass.states.async_all(REMOTE_DOMAIN) + + mock_itach_class.assert_called_once_with(None, "192.168.1.50", 4998) + mock_itach.addDevice.assert_called_once_with( + None, + 1, + 2, + "ON\nsendir-on\n", + ) + assert len(states) == 1 + assert states[0].name == DEVICE_DEFAULT_NAME + + +async def test_setup_platform_uses_empty_string_placeholders_for_empty_commands( + hass: HomeAssistant, +) -> None: + """Test setup converts empty command names and data to placeholders.""" + mock_itach = MagicMock() + mock_itach.ready.return_value = True + devices = [ + { + CONF_NAME: "TV", + "connaddr": 1, + "commands": [ + { + CONF_NAME: "", + "data": "", + }, + { + CONF_NAME: " ", + "data": " ", + }, + ], + } + ] + + with patch( + "homeassistant.components.itach.remote.pyitachip2ir.ITachIP2IR", + return_value=mock_itach, + ): + await _async_setup_itach_remote(hass, **{CONF_DEVICES: devices}) + + mock_itach.addDevice.assert_called_once_with( + "TV", + 1, + 1, + '""\n""\n""\n""\n', + ) From bbf91d7ee47c7a227d0a665cbb6be9f10c02807b Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 10 Jun 2026 17:52:43 +0200 Subject: [PATCH 076/404] Change update interval for UptimeRobot (#173435) --- homeassistant/components/uptimerobot/const.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/uptimerobot/const.py b/homeassistant/components/uptimerobot/const.py index ca2b5c204b85..a4eb81176f76 100644 --- a/homeassistant/components/uptimerobot/const.py +++ b/homeassistant/components/uptimerobot/const.py @@ -8,8 +8,10 @@ from homeassistant.const import Platform LOGGER: Logger = getLogger(__package__) -# The free plan is limited to 10 requests/minute -COORDINATOR_UPDATE_INTERVAL: timedelta = timedelta(seconds=10) +# The free plan is formally limited to 10 requests/minute +# But real world says 5 requests/minute is the real limit +# Opened a ticket with support with no response for 2 months +COORDINATOR_UPDATE_INTERVAL: timedelta = timedelta(seconds=15) DOMAIN: Final = "uptimerobot" PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] From 03523f96c294ffb81ab04ea5a40188d76f4fd704 Mon Sep 17 00:00:00 2001 From: Manu Date: Wed, 10 Jun 2026 18:02:23 +0200 Subject: [PATCH 077/404] Bump pysml to 0.1.8 (#173449) --- homeassistant/components/edl21/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json index d9d25c06d982..4970b73838cc 100644 --- a/homeassistant/components/edl21/manifest.json +++ b/homeassistant/components/edl21/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["sml"], - "requirements": ["pysml==0.1.7"] + "requirements": ["pysml==0.1.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 433455e3b638..1a65633fa8b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2564,7 +2564,7 @@ pysmarty2==0.10.3 pysmhi==2.0.0 # homeassistant.components.edl21 -pysml==0.1.7 +pysml==0.1.8 # homeassistant.components.smlight pysmlight==0.3.2 From 866437a0fd7aa23925a9071bdb600ebe6b564ef2 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Wed, 10 Jun 2026 19:23:36 +0300 Subject: [PATCH 078/404] Claude Fable support for Anthropic (#173455) --- .../components/anthropic/coordinator.py | 6 +- homeassistant/components/anthropic/repairs.py | 14 +-- tests/components/anthropic/__init__.py | 90 +++++++++++++++++-- .../anthropic/snapshots/test_config_flow.ambr | 8 ++ tests/components/anthropic/test_ai_task.py | 2 +- .../components/anthropic/test_config_flow.py | 2 +- .../components/anthropic/test_conversation.py | 4 +- 7 files changed, 102 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/anthropic/coordinator.py b/homeassistant/components/anthropic/coordinator.py index 6b42e801eab2..ac67728703c3 100644 --- a/homeassistant/components/anthropic/coordinator.py +++ b/homeassistant/components/anthropic/coordinator.py @@ -1,7 +1,6 @@ """Coordinator for the Anthropic integration.""" import datetime -import re import anthropic @@ -20,15 +19,12 @@ UPDATE_INTERVAL_DISCONNECTED = datetime.timedelta(minutes=1) type AnthropicConfigEntry = ConfigEntry[AnthropicCoordinator] -_model_short_form = re.compile(r"[^\d]-\d$") - - @callback def model_alias(model_id: str) -> str: """Resolve alias from versioned model name.""" if model_id[-2:-1] != "-" and not model_id.endswith("-preview"): model_id = model_id[:-9] - if _model_short_form.search(model_id): + if model_id.endswith("-4"): return model_id + "-0" return model_id diff --git a/homeassistant/components/anthropic/repairs.py b/homeassistant/components/anthropic/repairs.py index bbd34384b1e2..d16dd8a9bd60 100644 --- a/homeassistant/components/anthropic/repairs.py +++ b/homeassistant/components/anthropic/repairs.py @@ -65,18 +65,18 @@ class ModelDeprecatedRepairFlow(RepairsFlow): ] self._model_list_cache[entry.entry_id] = model_list - if "opus" in model: - family = "claude-opus" - elif "sonnet" in model: - family = "claude-sonnet" - else: - family = "claude-haiku" + family = ( + model.removeprefix("claude-") + .removesuffix("-preview") + .translate(str.maketrans("", "", "0123456789-.")) + or "haiku" + ) suggested_model = next( ( model_option["value"] for model_option in sorted( - (m for m in model_list if family in m["value"]), + (m for m in model_list if f"claude-{family}" in m["value"]), key=lambda x: x["value"], reverse=True, ) diff --git a/tests/components/anthropic/__init__.py b/tests/components/anthropic/__init__.py index 89025b3d15fe..cacd738bb37b 100644 --- a/tests/components/anthropic/__init__.py +++ b/tests/components/anthropic/__init__.py @@ -53,6 +53,80 @@ from anthropic.types.web_fetch_tool_result_block import ( ) model_list = [ + ModelInfo( + id="claude-fable-5", + capabilities=ModelCapabilities( + batch=CapabilitySupport(supported=True), + citations=CapabilitySupport(supported=True), + code_execution=CapabilitySupport(supported=True), + context_management=ContextManagementCapability( + clear_thinking_20251015=CapabilitySupport(supported=True), + clear_tool_uses_20250919=CapabilitySupport(supported=True), + compact_20260112=CapabilitySupport(supported=True), + supported=True, + ), + effort=EffortCapability( + high=CapabilitySupport(supported=True), + low=CapabilitySupport(supported=True), + max=CapabilitySupport(supported=True), + medium=CapabilitySupport(supported=True), + supported=True, + xhigh=CapabilitySupport(supported=True), + ), + image_input=CapabilitySupport(supported=True), + pdf_input=CapabilitySupport(supported=True), + structured_outputs=CapabilitySupport(supported=True), + thinking=ThinkingCapability( + supported=True, + types=ThinkingTypes( + adaptive=CapabilitySupport(supported=True), + enabled=CapabilitySupport(supported=False), + ), + ), + ), + created_at=datetime.datetime(2026, 6, 7, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Fable 5", + max_input_tokens=1000000, + max_tokens=128000, + type="model", + ), + ModelInfo( + id="claude-opus-4-8", + capabilities=ModelCapabilities( + batch=CapabilitySupport(supported=True), + citations=CapabilitySupport(supported=True), + code_execution=CapabilitySupport(supported=True), + context_management=ContextManagementCapability( + clear_thinking_20251015=CapabilitySupport(supported=True), + clear_tool_uses_20250919=CapabilitySupport(supported=True), + compact_20260112=CapabilitySupport(supported=True), + supported=True, + ), + effort=EffortCapability( + high=CapabilitySupport(supported=True), + low=CapabilitySupport(supported=True), + max=CapabilitySupport(supported=True), + medium=CapabilitySupport(supported=True), + supported=True, + xhigh=CapabilitySupport(supported=True), + ), + image_input=CapabilitySupport(supported=True), + pdf_input=CapabilitySupport(supported=True), + structured_outputs=CapabilitySupport(supported=True), + thinking=ThinkingCapability( + supported=True, + types=ThinkingTypes( + adaptive=CapabilitySupport(supported=True), + enabled=CapabilitySupport(supported=False), + ), + ), + ), + created_at=datetime.datetime(2026, 5, 28, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Opus 4.8", + max_input_tokens=1000000, + max_tokens=128000, + type="model", + ), ModelInfo( id="claude-opus-4-7", capabilities=ModelCapabilities( @@ -108,7 +182,7 @@ model_list = [ max=CapabilitySupport(supported=True), medium=CapabilitySupport(supported=True), supported=True, - xhigh=None, + xhigh=CapabilitySupport(supported=False), ), image_input=CapabilitySupport(supported=True), pdf_input=CapabilitySupport(supported=True), @@ -145,7 +219,7 @@ model_list = [ max=CapabilitySupport(supported=True), medium=CapabilitySupport(supported=True), supported=True, - xhigh=None, + xhigh=CapabilitySupport(supported=False), ), image_input=CapabilitySupport(supported=True), pdf_input=CapabilitySupport(supported=True), @@ -182,7 +256,7 @@ model_list = [ max=CapabilitySupport(supported=False), medium=CapabilitySupport(supported=True), supported=True, - xhigh=None, + xhigh=CapabilitySupport(supported=False), ), image_input=CapabilitySupport(supported=True), pdf_input=CapabilitySupport(supported=True), @@ -219,7 +293,7 @@ model_list = [ max=CapabilitySupport(supported=False), medium=CapabilitySupport(supported=False), supported=False, - xhigh=None, + xhigh=CapabilitySupport(supported=False), ), image_input=CapabilitySupport(supported=True), pdf_input=CapabilitySupport(supported=True), @@ -256,7 +330,7 @@ model_list = [ max=CapabilitySupport(supported=False), medium=CapabilitySupport(supported=False), supported=False, - xhigh=None, + xhigh=CapabilitySupport(supported=False), ), image_input=CapabilitySupport(supported=True), pdf_input=CapabilitySupport(supported=True), @@ -293,7 +367,7 @@ model_list = [ max=CapabilitySupport(supported=False), medium=CapabilitySupport(supported=False), supported=False, - xhigh=None, + xhigh=CapabilitySupport(supported=False), ), image_input=CapabilitySupport(supported=True), pdf_input=CapabilitySupport(supported=True), @@ -330,7 +404,7 @@ model_list = [ max=CapabilitySupport(supported=False), medium=CapabilitySupport(supported=False), supported=False, - xhigh=None, + xhigh=CapabilitySupport(supported=False), ), image_input=CapabilitySupport(supported=True), pdf_input=CapabilitySupport(supported=True), @@ -367,7 +441,7 @@ model_list = [ max=CapabilitySupport(supported=False), medium=CapabilitySupport(supported=False), supported=False, - xhigh=None, + xhigh=CapabilitySupport(supported=False), ), image_input=CapabilitySupport(supported=True), pdf_input=CapabilitySupport(supported=True), diff --git a/tests/components/anthropic/snapshots/test_config_flow.ambr b/tests/components/anthropic/snapshots/test_config_flow.ambr index 03b2a3f168f6..921984a70865 100644 --- a/tests/components/anthropic/snapshots/test_config_flow.ambr +++ b/tests/components/anthropic/snapshots/test_config_flow.ambr @@ -1,6 +1,14 @@ # serializer version: 1 # name: test_model_list list([ + dict({ + 'label': 'Claude Fable 5', + 'value': 'claude-fable-5', + }), + dict({ + 'label': 'Claude Opus 4.8', + 'value': 'claude-opus-4-8', + }), dict({ 'label': 'Claude Opus 4.7', 'value': 'claude-opus-4-7', diff --git a/tests/components/anthropic/test_ai_task.py b/tests/components/anthropic/test_ai_task.py index 2c18c03bf231..f183100d4834 100644 --- a/tests/components/anthropic/test_ai_task.py +++ b/tests/components/anthropic/test_ai_task.py @@ -101,7 +101,7 @@ async def test_stream_wrong_type( mock_create_stream.return_value = Message( type="message", id="message_id", - model="claude-opus-4-6", + model="claude-fable-5", role="assistant", content=[TextBlock(type="text", text="This is not a stream")], usage=Usage(input_tokens=42, output_tokens=42), diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index a21384aedd78..d26036d721fa 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -659,7 +659,7 @@ async def test_invalid_model( ( # Model with thinking effort options { CONF_RECOMMENDED: False, - CONF_CHAT_MODEL: "claude-opus-4-6", + CONF_CHAT_MODEL: "claude-fable-5", CONF_PROMPT: "bla", CONF_PROMPT_CACHING: "automatic", CONF_TOOL_SEARCH: True, diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 15cf413336de..ae1a5af7b511 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -656,7 +656,7 @@ async def test_stream_wrong_type( mock_create_stream.return_value = Message( type="message", id="message_id", - model="claude-opus-4-6", + model="claude-fable-5", role="assistant", content=[TextBlock(type="text", text="This is not a stream")], usage=Usage(input_tokens=42, output_tokens=42), @@ -1897,7 +1897,7 @@ async def test_web_fetch_error( data={ CONF_LLM_HASS_API: llm.LLM_API_ASSIST, CONF_CODE_EXECUTION: True, - CONF_CHAT_MODEL: "claude-opus-4-6", + CONF_CHAT_MODEL: "claude-fable-5", CONF_WEB_FETCH: True, CONF_WEB_FETCH_MAX_USES: 5, }, From 67740405a8cf281e21a13886b2086655908c2e04 Mon Sep 17 00:00:00 2001 From: Michael Davie Date: Wed, 10 Jun 2026 12:28:04 -0400 Subject: [PATCH 079/404] Add radar camera options flow to Environment Canada (#173415) Co-authored-by: Claude Opus 4.8 Co-authored-by: Joost Lekkerkerker --- .../components/environment_canada/__init__.py | 25 ++- .../environment_canada/config_flow.py | 89 ++++++++- .../components/environment_canada/const.py | 16 ++ .../environment_canada/strings.json | 27 +++ .../components/environment_canada/__init__.py | 28 ++- .../components/environment_canada/conftest.py | 16 +- .../environment_canada/test_config_flow.py | 170 +++++++++++++++++- 7 files changed, 358 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index fc9733a4e375..2572c735fa74 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -11,7 +11,20 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import CONF_STATION, DOMAIN +from .const import ( + CONF_RADAR_LAYER, + CONF_RADAR_LEGEND, + CONF_RADAR_OPACITY, + CONF_RADAR_RADIUS, + CONF_RADAR_TIMESTAMP, + CONF_STATION, + DEFAULT_RADAR_LAYER, + DEFAULT_RADAR_LEGEND, + DEFAULT_RADAR_OPACITY, + DEFAULT_RADAR_RADIUS, + DEFAULT_RADAR_TIMESTAMP, + DOMAIN, +) from .coordinator import ECConfigEntry, ECDataUpdateCoordinator, ECRuntimeData from .services import async_setup_services @@ -54,7 +67,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) -> errors = errors + 1 _LOGGER.warning("Unable to retrieve Environment Canada weather") - radar_data = ECMap(coordinates=(lat, lon), layer="precip_type", legend=False) + options = config_entry.options + radar_data = ECMap( + coordinates=(lat, lon), + layer=options.get(CONF_RADAR_LAYER, DEFAULT_RADAR_LAYER), + legend=options.get(CONF_RADAR_LEGEND, DEFAULT_RADAR_LEGEND), + timestamp=options.get(CONF_RADAR_TIMESTAMP, DEFAULT_RADAR_TIMESTAMP), + layer_opacity=int(options.get(CONF_RADAR_OPACITY, DEFAULT_RADAR_OPACITY)), + radius=int(options.get(CONF_RADAR_RADIUS, DEFAULT_RADAR_RADIUS)), + ) radar_coordinator = ECDataUpdateCoordinator( hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL ) diff --git a/homeassistant/components/environment_canada/config_flow.py b/homeassistant/components/environment_canada/config_flow.py index 031589fd65fa..e2dc72d2399c 100644 --- a/homeassistant/components/environment_canada/config_flow.py +++ b/homeassistant/components/environment_canada/config_flow.py @@ -9,17 +9,42 @@ from env_canada import ECWeather, ec_exc from env_canada.ec_weather import get_ec_sites_list import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( + BooleanSelector, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, SelectOptionDict, SelectSelector, SelectSelectorConfig, SelectSelectorMode, ) -from .const import CONF_STATION, CONF_TITLE, DOMAIN +from .const import ( + CONF_RADAR_LAYER, + CONF_RADAR_LEGEND, + CONF_RADAR_OPACITY, + CONF_RADAR_RADIUS, + CONF_RADAR_TIMESTAMP, + CONF_STATION, + CONF_TITLE, + DEFAULT_RADAR_LAYER, + DEFAULT_RADAR_LEGEND, + DEFAULT_RADAR_OPACITY, + DEFAULT_RADAR_RADIUS, + DEFAULT_RADAR_TIMESTAMP, + DOMAIN, + RADAR_LAYERS, +) _LOGGER = logging.getLogger(__name__) @@ -57,6 +82,14 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 _station_codes: list[dict[str, str]] | None = None + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlowHandler: + """Return the options flow handler.""" + return OptionsFlowHandler() + async def _get_station_codes(self) -> list[dict[str, str]]: """Get station codes, cached after first call.""" if self._station_codes is None: @@ -127,3 +160,55 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=data_schema, errors=errors ) + + +class OptionsFlowHandler(OptionsFlowWithReload): + """Handle Environment Canada radar camera options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the radar camera options.""" + if user_input is not None: + return self.async_create_entry(data=user_input) + + options = self.config_entry.options + data_schema = vol.Schema( + { + vol.Required( + CONF_RADAR_LAYER, + default=options.get(CONF_RADAR_LAYER, DEFAULT_RADAR_LAYER), + ): SelectSelector( + SelectSelectorConfig( + options=RADAR_LAYERS, + translation_key="radar_layer", + ) + ), + vol.Required( + CONF_RADAR_LEGEND, + default=options.get(CONF_RADAR_LEGEND, DEFAULT_RADAR_LEGEND), + ): BooleanSelector(), + vol.Required( + CONF_RADAR_TIMESTAMP, + default=options.get(CONF_RADAR_TIMESTAMP, DEFAULT_RADAR_TIMESTAMP), + ): BooleanSelector(), + vol.Required( + CONF_RADAR_OPACITY, + default=options.get(CONF_RADAR_OPACITY, DEFAULT_RADAR_OPACITY), + ): NumberSelector( + NumberSelectorConfig( + min=0, max=100, step=1, mode=NumberSelectorMode.SLIDER + ) + ), + vol.Required( + CONF_RADAR_RADIUS, + default=options.get(CONF_RADAR_RADIUS, DEFAULT_RADAR_RADIUS), + ): NumberSelector( + NumberSelectorConfig( + min=10, max=2000, step=10, unit_of_measurement="km" + ) + ), + } + ) + + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/environment_canada/const.py b/homeassistant/components/environment_canada/const.py index c2b58d8dcce1..39a9f3a949a7 100644 --- a/homeassistant/components/environment_canada/const.py +++ b/homeassistant/components/environment_canada/const.py @@ -6,3 +6,19 @@ CONF_STATION = "station" CONF_TITLE = "title" DOMAIN = "environment_canada" SERVICE_ENVIRONMENT_CANADA_FORECASTS = "get_forecasts" + +CONF_RADAR_LAYER = "radar_layer" +CONF_RADAR_LEGEND = "radar_legend" +CONF_RADAR_TIMESTAMP = "radar_timestamp" +CONF_RADAR_OPACITY = "radar_opacity" +CONF_RADAR_RADIUS = "radar_radius" + +RADAR_LAYERS = ["rain", "snow", "precip_type"] + +# Defaults preserve the radar behaviour from before the options flow existed: +# the precipitation-type layer with the legend hidden. +DEFAULT_RADAR_LAYER = "precip_type" +DEFAULT_RADAR_LEGEND = False +DEFAULT_RADAR_TIMESTAMP = True +DEFAULT_RADAR_OPACITY = 65 +DEFAULT_RADAR_RADIUS = 200 diff --git a/homeassistant/components/environment_canada/strings.json b/homeassistant/components/environment_canada/strings.json index 1f159bf9c997..93acc0ecc558 100644 --- a/homeassistant/components/environment_canada/strings.json +++ b/homeassistant/components/environment_canada/strings.json @@ -117,6 +117,33 @@ "message": "Environment Canada is not connected" } }, + "options": { + "step": { + "init": { + "data": { + "radar_layer": "Radar type", + "radar_legend": "Show legend", + "radar_opacity": "Radar opacity", + "radar_radius": "Map radius", + "radar_timestamp": "Show timestamp" + }, + "data_description": { + "radar_opacity": "Opacity of the radar layer overlay (0-100)", + "radar_radius": "Radius of the radar map in kilometres" + }, + "title": "Radar camera options" + } + } + }, + "selector": { + "radar_layer": { + "options": { + "precip_type": "Precipitation type", + "rain": "Rain", + "snow": "Snow" + } + } + }, "services": { "get_alerts": { "description": "Retrieves the alerts from the selected weather service.", diff --git a/tests/components/environment_canada/__init__.py b/tests/components/environment_canada/__init__.py index 7137b00595ab..354474165b1c 100644 --- a/tests/components/environment_canada/__init__.py +++ b/tests/components/environment_canada/__init__.py @@ -1,6 +1,7 @@ """Tests for the Environment Canada integration.""" from datetime import UTC, datetime +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from homeassistant.components.environment_canada.const import CONF_STATION, DOMAIN @@ -17,10 +18,10 @@ FIXTURE_USER_INPUT = { } -async def init_integration(hass: HomeAssistant, ec_data) -> MockConfigEntry: - """Set up the Environment Canada integration in Home Assistant.""" +def build_mocks(ec_data) -> tuple[MagicMock, MagicMock, MagicMock]: + """Build the weather, AQHI and radar library mocks used during setup.""" - def mock_ec(): + def mock_ec() -> MagicMock: ec_mock = MagicMock() ec_mock.station_id = FIXTURE_USER_INPUT[CONF_STATION] ec_mock.lat = FIXTURE_USER_INPUT[CONF_LATITUDE] @@ -29,9 +30,6 @@ async def init_integration(hass: HomeAssistant, ec_data) -> MockConfigEntry: ec_mock.update = AsyncMock() return ec_mock - config_entry = MockConfigEntry(domain=DOMAIN, data=FIXTURE_USER_INPUT, title="Home") - config_entry.add_to_hass(hass) - weather_mock = mock_ec() ec_data["metadata"].timestamp = datetime(2022, 10, 4, tzinfo=UTC) weather_mock.conditions = ec_data["conditions"] @@ -47,6 +45,22 @@ async def init_integration(hass: HomeAssistant, ec_data) -> MockConfigEntry: radar_mock.metadata = {"attribution": "Data provided by Environment Canada"} radar_mock.clear_cache = MagicMock() + return weather_mock, mock_ec(), radar_mock + + +async def init_integration( + hass: HomeAssistant, + ec_data, + options: dict[str, Any] | None = None, +) -> MockConfigEntry: + """Set up the Environment Canada integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data=FIXTURE_USER_INPUT, title="Home", options=options or {} + ) + config_entry.add_to_hass(hass) + + weather_mock, aqhi_mock, radar_mock = build_mocks(ec_data) + with ( patch( "homeassistant.components.environment_canada.ECWeather", @@ -54,7 +68,7 @@ async def init_integration(hass: HomeAssistant, ec_data) -> MockConfigEntry: ), patch( "homeassistant.components.environment_canada.ECAirQuality", - return_value=mock_ec(), + return_value=aqhi_mock, ), patch( "homeassistant.components.environment_canada.ECMap", diff --git a/tests/components/environment_canada/conftest.py b/tests/components/environment_canada/conftest.py index 3c7683ad0eb2..df8637946b38 100644 --- a/tests/components/environment_canada/conftest.py +++ b/tests/components/environment_canada/conftest.py @@ -7,7 +7,21 @@ import json from env_canada.ec_weather import MetaData import pytest -from tests.common import load_fixture +from homeassistant.components.environment_canada.const import DOMAIN + +from . import FIXTURE_USER_INPUT + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data=FIXTURE_USER_INPUT, + title="Home", + ) @pytest.fixture diff --git a/tests/components/environment_canada/test_config_flow.py b/tests/components/environment_canada/test_config_flow.py index 681777ed85ad..7a5d88b64bf3 100644 --- a/tests/components/environment_canada/test_config_flow.py +++ b/tests/components/environment_canada/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Environment Canada (EC) config flow.""" +from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import xml.etree.ElementTree as ET @@ -7,11 +8,26 @@ import aiohttp import pytest from homeassistant import config_entries -from homeassistant.components.environment_canada.const import CONF_STATION, DOMAIN +from homeassistant.components.environment_canada.const import ( + CONF_RADAR_LAYER, + CONF_RADAR_LEGEND, + CONF_RADAR_OPACITY, + CONF_RADAR_RADIUS, + CONF_RADAR_TIMESTAMP, + CONF_STATION, + DEFAULT_RADAR_LAYER, + DEFAULT_RADAR_LEGEND, + DEFAULT_RADAR_OPACITY, + DEFAULT_RADAR_RADIUS, + DEFAULT_RADAR_TIMESTAMP, + DOMAIN, +) from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import build_mocks, init_integration + from tests.common import MockConfigEntry FAKE_CONFIG = { @@ -183,3 +199,155 @@ async def test_coordinates_without_station(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == FAKE_CONFIG assert result["title"] == FAKE_TITLE + + +async def _setup_with_options( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + ec_data: dict[str, Any], + options: dict[str, Any], +) -> MagicMock: + """Set up the integration and return the patched ECMap constructor mock.""" + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry(mock_config_entry, options=options) + + weather_mock, aqhi_mock, radar_mock = build_mocks(ec_data) + ecmap = MagicMock(return_value=radar_mock) + + with ( + patch( + "homeassistant.components.environment_canada.ECWeather", + return_value=weather_mock, + ), + patch( + "homeassistant.components.environment_canada.ECAirQuality", + return_value=aqhi_mock, + ), + patch("homeassistant.components.environment_canada.ECMap", ecmap), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return ecmap + + +async def test_options_flow_form(hass: HomeAssistant, ec_data: dict[str, Any]) -> None: + """Test the options form shows all radar fields.""" + config_entry = await init_integration(hass, ec_data) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + schema_keys = {str(k) for k in result["data_schema"].schema} + assert schema_keys == { + CONF_RADAR_LAYER, + CONF_RADAR_LEGEND, + CONF_RADAR_TIMESTAMP, + CONF_RADAR_OPACITY, + CONF_RADAR_RADIUS, + } + + +async def test_options_flow_save(hass: HomeAssistant, ec_data: dict[str, Any]) -> None: + """Test submitting the options form stores the values and reloads the entry.""" + config_entry = await init_integration(hass, ec_data) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + new_options = { + CONF_RADAR_LAYER: "rain", + CONF_RADAR_LEGEND: True, + CONF_RADAR_TIMESTAMP: False, + CONF_RADAR_OPACITY: 30, + CONF_RADAR_RADIUS: 100, + } + with patch( + "homeassistant.components.environment_canada.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.options.async_configure( + result["flow_id"], new_options + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.options == new_options + assert mock_setup_entry.called + + +async def test_options_flow_prefills_saved_options( + hass: HomeAssistant, ec_data: dict[str, Any] +) -> None: + """Test the options form is pre-filled with previously saved values.""" + saved_options = { + CONF_RADAR_LAYER: "snow", + CONF_RADAR_LEGEND: True, + CONF_RADAR_TIMESTAMP: False, + CONF_RADAR_OPACITY: 50, + CONF_RADAR_RADIUS: 300, + } + config_entry = await init_integration(hass, ec_data, options=saved_options) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + defaults = {str(k): k.default() for k in result["data_schema"].schema} + assert defaults[CONF_RADAR_LAYER] == "snow" + assert defaults[CONF_RADAR_LEGEND] is True + assert defaults[CONF_RADAR_TIMESTAMP] is False + assert defaults[CONF_RADAR_OPACITY] == 50 + assert defaults[CONF_RADAR_RADIUS] == 300 + + +@pytest.mark.parametrize( + ("options", "expected"), + [ + pytest.param( + {}, + { + "layer": DEFAULT_RADAR_LAYER, + "legend": DEFAULT_RADAR_LEGEND, + "timestamp": DEFAULT_RADAR_TIMESTAMP, + "layer_opacity": DEFAULT_RADAR_OPACITY, + "radius": DEFAULT_RADAR_RADIUS, + }, + id="defaults", + ), + pytest.param( + { + CONF_RADAR_LAYER: "snow", + CONF_RADAR_LEGEND: True, + CONF_RADAR_TIMESTAMP: False, + CONF_RADAR_OPACITY: 40.0, + CONF_RADAR_RADIUS: 150.0, + }, + { + "layer": "snow", + "legend": True, + "timestamp": False, + "layer_opacity": 40, + "radius": 150, + }, + id="custom", + ), + ], +) +async def test_ecmap_built_from_options( + hass: HomeAssistant, + ec_data: dict[str, Any], + mock_config_entry: MockConfigEntry, + options: dict[str, Any], + expected: dict[str, Any], +) -> None: + """Test the radar ECMap is constructed from the saved options.""" + ecmap = await _setup_with_options(hass, mock_config_entry, ec_data, options) + + ecmap.assert_called_once() + kwargs = ecmap.call_args.kwargs + assert kwargs["layer"] == expected["layer"] + assert kwargs["legend"] is expected["legend"] + assert kwargs["timestamp"] is expected["timestamp"] + assert kwargs["layer_opacity"] == expected["layer_opacity"] + assert isinstance(kwargs["layer_opacity"], int) + assert kwargs["radius"] == expected["radius"] + assert isinstance(kwargs["radius"], int) From 34956c1548e4407bd30185d15cef72917799d66b Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 10 Jun 2026 18:28:38 +0200 Subject: [PATCH 080/404] Redact more fields in diagnostics for Alexa devices (#173446) --- .../components/alexa_devices/diagnostics.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alexa_devices/diagnostics.py b/homeassistant/components/alexa_devices/diagnostics.py index 0cc8f201e8a5..59e84384f7a7 100644 --- a/homeassistant/components/alexa_devices/diagnostics.py +++ b/homeassistant/components/alexa_devices/diagnostics.py @@ -12,7 +12,18 @@ from homeassistant.helpers.device_registry import DeviceEntry from .coordinator import AmazonConfigEntry -TO_REDACT = {CONF_PASSWORD, CONF_USERNAME, CONF_NAME, "title"} +TO_REDACT = { + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + "access_token", + "adp_token", + "device_private_key", + "refresh_token", + "store_authentication_cookie", + "title", + "website_cookies", +} async def async_get_config_entry_diagnostics( From 3435cfeaab0a59f65e325666ffd67ccc08d5f3bb Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 10 Jun 2026 18:34:01 +0200 Subject: [PATCH 081/404] Use dt util in gardena bluetooth (#173444) --- homeassistant/components/gardena_bluetooth/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index 19ca315c456b..db8e7f964832 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -2,7 +2,7 @@ from collections.abc import Callable from dataclasses import dataclass, field -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta from gardena_bluetooth.const import ( AquaContourBattery, @@ -279,7 +279,7 @@ class GardenaBluetoothRemainSensor(GardenaBluetoothEntity, SensorEntity): super()._handle_coordinator_update() return - time = datetime.now(UTC) + timedelta(seconds=value) # pylint: disable=home-assistant-enforce-utcnow + time = dt_util.utcnow() + timedelta(seconds=value) if not self._attr_native_value: self._attr_native_value = time super()._handle_coordinator_update() From 2ab3e0770f8d445137b428e6b8078b4d5b6e8d83 Mon Sep 17 00:00:00 2001 From: Rasmus Graham <2124386+rasmusbe@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:19:17 +0200 Subject: [PATCH 082/404] Bump vsure to 2.7.1 (#173470) --- homeassistant/components/verisure/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/verisure/manifest.json b/homeassistant/components/verisure/manifest.json index 17379df01a11..0102c88cfcfc 100644 --- a/homeassistant/components/verisure/manifest.json +++ b/homeassistant/components/verisure/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["verisure"], - "requirements": ["vsure==2.7.0"] + "requirements": ["vsure==2.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1a65633fa8b0..1f6d4db98e62 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3329,7 +3329,7 @@ volkszaehler==0.4.0 volvocarsapi==0.4.3 # homeassistant.components.verisure -vsure==2.7.0 +vsure==2.7.1 # homeassistant.components.vasttrafik vtjp==0.2.1 From eb4568fe54d272694d4e6190a529ebca0f9c7046 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 10 Jun 2026 22:23:07 +0200 Subject: [PATCH 083/404] Tado refactor to use dt_util (#173440) --- homeassistant/components/tado/coordinator.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index 5b38768cb513..9c9027638545 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util from .const import ( CONF_FALLBACK, @@ -155,9 +156,7 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Tado resets somewhere between 12:00 and 13:00, Berlin time # So let's pretend we're in Berlin... - reset_time = datetime.now( # pylint: disable=home-assistant-enforce-now - ZoneInfo("Europe/Berlin") - ) + reset_time = dt_util.now(ZoneInfo("Europe/Berlin")) today_reset = datetime.combine( reset_time.date(), From 392f7b7260c17b4717764386121e6b3153784048 Mon Sep 17 00:00:00 2001 From: Florent Thoumie Date: Wed, 10 Jun 2026 15:31:27 -0500 Subject: [PATCH 084/404] iaqualink: add diagnostics support (#169518) --- .../components/iaqualink/diagnostics.py | 31 ++++++++++ .../components/iaqualink/quality_scale.yaml | 2 +- .../iaqualink/snapshots/test_diagnostics.ambr | 29 +++++++++ .../components/iaqualink/test_diagnostics.py | 60 +++++++++++++++++++ 4 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/iaqualink/diagnostics.py create mode 100644 tests/components/iaqualink/snapshots/test_diagnostics.ambr create mode 100644 tests/components/iaqualink/test_diagnostics.py diff --git a/homeassistant/components/iaqualink/diagnostics.py b/homeassistant/components/iaqualink/diagnostics.py new file mode 100644 index 000000000000..58fae08093de --- /dev/null +++ b/homeassistant/components/iaqualink/diagnostics.py @@ -0,0 +1,31 @@ +"""Diagnostics platform for iAquaLink.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from . import AqualinkConfigEntry + +TO_REDACT = {"serial", "serial_number"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: AqualinkConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + systems = [ + { + "online": coordinator.system.online, + "data": {k: v for k, v in coordinator.system.data.items() if k != "name"}, + "devices": { + name: {"class": obj.__class__.__name__, "data": obj.data} + for name, obj in ( + getattr(coordinator.system, "devices", None) or {} + ).items() + }, + } + for coordinator in entry.runtime_data.coordinators.values() + ] + + return {"systems": async_redact_data(systems, TO_REDACT)} diff --git a/homeassistant/components/iaqualink/quality_scale.yaml b/homeassistant/components/iaqualink/quality_scale.yaml index 26af8ae41ae5..59368e789b99 100644 --- a/homeassistant/components/iaqualink/quality_scale.yaml +++ b/homeassistant/components/iaqualink/quality_scale.yaml @@ -39,7 +39,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: This integration uses a cloud account. diff --git a/tests/components/iaqualink/snapshots/test_diagnostics.ambr b/tests/components/iaqualink/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..95506ab6dd9f --- /dev/null +++ b/tests/components/iaqualink/snapshots/test_diagnostics.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'systems': list([ + dict({ + 'data': dict({ + 'serial_number': '**REDACTED**', + }), + 'devices': dict({ + 'aux_1': dict({ + 'class': 'IaquaLightSwitch', + 'data': dict({ + 'name': 'aux_1', + 'state': '1', + }), + }), + 'pool_temp': dict({ + 'class': 'IaquaSensor', + 'data': dict({ + 'name': 'pool_temp', + 'value': '82', + }), + }), + }), + 'online': True, + }), + ]), + }) +# --- diff --git a/tests/components/iaqualink/test_diagnostics.py b/tests/components/iaqualink/test_diagnostics.py new file mode 100644 index 000000000000..a29c648b78f8 --- /dev/null +++ b/tests/components/iaqualink/test_diagnostics.py @@ -0,0 +1,60 @@ +"""Tests for iAquaLink diagnostics.""" + +from unittest.mock import AsyncMock, patch + +from iaqualink.client import AqualinkClient +from iaqualink.systems.iaqua.device import IaquaLightSwitch, IaquaSensor +from iaqualink.systems.iaqua.system import IaquaSystem +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from .conftest import get_aqualink_device, get_aqualink_system + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + client: AqualinkClient, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + config_entry.add_to_hass(hass) + + system = get_aqualink_system(client, cls=IaquaSystem) + system.data["serial_number"] = "SN00001" + system.online = True + system.update = AsyncMock() + systems = {system.serial: system} + light = get_aqualink_device( + system, name="aux_1", cls=IaquaLightSwitch, data={"state": "1"} + ) + sensor = get_aqualink_device( + system, name="pool_temp", cls=IaquaSensor, data={"value": "82"} + ) + devices = {light.name: light, sensor.name: sensor} + system.devices = devices + system.get_devices = AsyncMock(return_value=devices) + + with ( + patch( + "homeassistant.components.iaqualink.AqualinkClient.login", + return_value=None, + ), + patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + return_value=systems, + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) From 06d8570e2ceb7ddcd1a563e1fae5bb009dbe450d Mon Sep 17 00:00:00 2001 From: James Myatt Date: Wed, 10 Jun 2026 21:34:48 +0100 Subject: [PATCH 085/404] (todo) Fix status field description (#173458) --- homeassistant/components/todo/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index eb6fe5b9b621..812477c74564 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -125,7 +125,7 @@ "name": "Rename item" }, "status": { - "description": "A status or confirmation of the to-do item.", + "description": "A status for the to-do item.", "name": "Set status" } }, From d656a1c091e2e1783353b1f745898748ec174127 Mon Sep 17 00:00:00 2001 From: James Myatt Date: Wed, 10 Jun 2026 21:36:08 +0100 Subject: [PATCH 086/404] Fix docstrings in shopping_list (#173462) --- homeassistant/components/shopping_list/todo.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shopping_list/todo.py b/homeassistant/components/shopping_list/todo.py index 61b9c0b80482..183132977aad 100644 --- a/homeassistant/components/shopping_list/todo.py +++ b/homeassistant/components/shopping_list/todo.py @@ -51,7 +51,7 @@ class ShoppingTodoListEntity(TodoListEntity): ) async def async_update_todo_item(self, item: TodoItem) -> None: - """Update an item to the To-do list.""" + """Update an item in the To-do list.""" data = { "name": item.summary, "complete": item.status == TodoItemStatus.COMPLETED, @@ -64,7 +64,7 @@ class ShoppingTodoListEntity(TodoListEntity): ) from err async def async_delete_todo_items(self, uids: list[str]) -> None: - """Add an item to the To-do list.""" + """Delete items from the To-do list.""" await self._data.async_remove_items(set(uids)) async def async_move_todo_item( From 7bedf8074d46dae2f91956942684eb8e236b6e03 Mon Sep 17 00:00:00 2001 From: Nikolai Rahimi Date: Wed, 10 Jun 2026 16:36:40 -0400 Subject: [PATCH 087/404] Add debug logging for Mitsubishi Comfort polling failures (#173364) --- .../components/mitsubishi_comfort/coordinator.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/mitsubishi_comfort/coordinator.py b/homeassistant/components/mitsubishi_comfort/coordinator.py index 38d642baf1e3..312b5db421d7 100644 --- a/homeassistant/components/mitsubishi_comfort/coordinator.py +++ b/homeassistant/components/mitsubishi_comfort/coordinator.py @@ -42,12 +42,23 @@ class MitsubishiComfortCoordinator(DataUpdateCoordinator[IndoorUnit | KumoStatio try: success = await self.device.update_status() except Exception as err: + # The user-facing UpdateFailed message is translated and omits the IP; + # log it here so the failing address is visible in debug logs. + _LOGGER.debug( + "Error polling %s at %s: %s", + self.device.name, + self.device.address, + err, + ) raise UpdateFailed( translation_domain=DOMAIN, translation_key="communication_error", translation_placeholders={"device_name": self.device.name}, ) from err if not success: + _LOGGER.debug( + "%s at %s returned no data", self.device.name, self.device.address + ) raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed", From 5b083f7959c8bf808ee91e583d67c3738077bc58 Mon Sep 17 00:00:00 2001 From: Rob Bierbooms Date: Wed, 10 Jun 2026 22:41:08 +0200 Subject: [PATCH 088/404] Solve issue with double slash in url when writing data to InfluxDB (#173395) --- homeassistant/components/influxdb/__init__.py | 2 +- tests/components/influxdb/test_init.py | 48 ++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py index ab9b9560079b..f5609b8018f1 100644 --- a/homeassistant/components/influxdb/__init__.py +++ b/homeassistant/components/influxdb/__init__.py @@ -423,7 +423,7 @@ def get_influx_connection( # noqa: C901 if CONF_HOST in conf: kwargs[CONF_HOST] = conf[CONF_HOST] - if (path := conf.get(CONF_PATH)) is not None: + if (path := conf.get(CONF_PATH)) is not None and path != "/": kwargs[CONF_PATH] = path if (port := conf.get(CONF_PORT)) is not None: diff --git a/tests/components/influxdb/test_init.py b/tests/components/influxdb/test_init.py index 2ff45d70d072..0eface64cb7e 100644 --- a/tests/components/influxdb/test_init.py +++ b/tests/components/influxdb/test_init.py @@ -13,7 +13,13 @@ import pytest from homeassistant.components import influxdb from homeassistant.components.influxdb.const import DEFAULT_BUCKET, DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON, STATE_STANDBY +from homeassistant.const import ( + CONF_PATH, + PERCENTAGE, + STATE_OFF, + STATE_ON, + STATE_STANDBY, +) from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component @@ -321,6 +327,46 @@ async def test_setup_config_ssl( assert expected_client_args.items() <= mock_client.call_args.kwargs.items() +@pytest.mark.parametrize( + ("mock_client", "config_ext", "expected_path"), + [ + pytest.param( + influxdb.DEFAULT_API_VERSION, + {CONF_PATH: "/"}, + None, + id="root_path_excluded", + ), + pytest.param( + influxdb.DEFAULT_API_VERSION, + {CONF_PATH: "/custom_path"}, + "/custom_path", + id="custom_path_included", + ), + pytest.param( + influxdb.DEFAULT_API_VERSION, + {}, + None, + id="no_path_excluded", + ), + ], + indirect=["mock_client"], +) +async def test_setup_config_path( + hass: HomeAssistant, mock_client, config_ext: dict, expected_path: str | None +) -> None: + """Test that path='/' is not passed to InfluxDBClient, but other paths are.""" + config = BASE_V1_CONFIG.copy() + config.update(config_ext) + + mock_entry = MockConfigEntry(domain=DOMAIN, data=config) + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + assert mock_client.call_args.kwargs.get(CONF_PATH) == expected_path + + @pytest.mark.parametrize( ("mock_client", "get_write_api", "config_ext"), [ From f48a4720e5b9feac95426110ef1894711d2342d8 Mon Sep 17 00:00:00 2001 From: Colin <486199+c00w@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:46:00 -0600 Subject: [PATCH 089/404] Update openevse quality_scale (#172801) --- .../components/openevse/quality_scale.yaml | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/openevse/quality_scale.yaml b/homeassistant/components/openevse/quality_scale.yaml index da2bf2cf8d3c..954222053c5b 100644 --- a/homeassistant/components/openevse/quality_scale.yaml +++ b/homeassistant/components/openevse/quality_scale.yaml @@ -26,31 +26,31 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt comment: Integration has no options flow. - docs-installation-parameters: todo + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold devices: done - diagnostics: todo + diagnostics: done discovery: done discovery-update-info: done - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: Integration supports a single device per config entry. @@ -71,4 +71,4 @@ rules: # Platinum async-dependency: done inject-websession: done - strict-typing: todo + strict-typing: done From c02147f38646c2ba0d750318c22946530e49e3d5 Mon Sep 17 00:00:00 2001 From: Stefan S Date: Wed, 10 Jun 2026 23:27:20 +0200 Subject: [PATCH 090/404] Add integration kaku_rc (KlikAanKlikUit) (#170841) --- CODEOWNERS | 2 + .../components/klik_aan_klik_uit/__init__.py | 55 ++++++ .../klik_aan_klik_uit/config_flow.py | 187 ++++++++++++++++++ .../components/klik_aan_klik_uit/const.py | 19 ++ .../klik_aan_klik_uit/manifest.json | 11 ++ .../klik_aan_klik_uit/quality_scale.yaml | 68 +++++++ .../components/klik_aan_klik_uit/strings.json | 41 ++++ .../components/klik_aan_klik_uit/switch.py | 112 +++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + .../components/klik_aan_klik_uit/__init__.py | 1 + .../components/klik_aan_klik_uit/conftest.py | 52 +++++ .../klik_aan_klik_uit/test_config_flow.py | 152 ++++++++++++++ .../klik_aan_klik_uit/test_switch.py | 57 ++++++ 14 files changed, 764 insertions(+) create mode 100644 homeassistant/components/klik_aan_klik_uit/__init__.py create mode 100644 homeassistant/components/klik_aan_klik_uit/config_flow.py create mode 100644 homeassistant/components/klik_aan_klik_uit/const.py create mode 100644 homeassistant/components/klik_aan_klik_uit/manifest.json create mode 100644 homeassistant/components/klik_aan_klik_uit/quality_scale.yaml create mode 100644 homeassistant/components/klik_aan_klik_uit/strings.json create mode 100644 homeassistant/components/klik_aan_klik_uit/switch.py create mode 100644 tests/components/klik_aan_klik_uit/__init__.py create mode 100644 tests/components/klik_aan_klik_uit/conftest.py create mode 100644 tests/components/klik_aan_klik_uit/test_config_flow.py create mode 100644 tests/components/klik_aan_klik_uit/test_switch.py diff --git a/CODEOWNERS b/CODEOWNERS index 4a4440818814..6a0b12c04907 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -947,6 +947,8 @@ CLAUDE.md @home-assistant/core /tests/components/kiosker/ @Claeysson /homeassistant/components/kitchen_sink/ @home-assistant/core /tests/components/kitchen_sink/ @home-assistant/core +/homeassistant/components/klik_aan_klik_uit/ @Phunkafizer +/tests/components/klik_aan_klik_uit/ @Phunkafizer /homeassistant/components/kmtronic/ @dgomes /tests/components/kmtronic/ @dgomes /homeassistant/components/knocki/ @joostlek @jgatto1 @JakeBosh diff --git a/homeassistant/components/klik_aan_klik_uit/__init__.py b/homeassistant/components/klik_aan_klik_uit/__init__.py new file mode 100644 index 000000000000..4da4b4954e82 --- /dev/null +++ b/homeassistant/components/klik_aan_klik_uit/__init__.py @@ -0,0 +1,55 @@ +"""The KlikAanKlikUit RC integration.""" + +from dataclasses import dataclass + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CONF_TRANSMITTER + + +@dataclass(slots=True) +class KlikAanKlikUitRuntimeData: + """Runtime data for the KlikAanKlikUit integration.""" + + transmitter_entity_id: str + + +type KlikAanKlikUitConfigEntry = ConfigEntry[KlikAanKlikUitRuntimeData] + + +PLATFORMS: list[Platform] = [Platform.SWITCH] + + +async def async_setup_entry( + hass: HomeAssistant, entry: KlikAanKlikUitConfigEntry +) -> bool: + """Setup KlikAanKlikUit RC from a config entry.""" + transmitter_entity_id = entry.data[CONF_TRANSMITTER] + if hass.states.get(transmitter_entity_id) is None: + raise ConfigEntryNotReady( + f"RF transmitter entity {transmitter_entity_id} is not available" + ) + + entry.runtime_data = KlikAanKlikUitRuntimeData( + transmitter_entity_id=transmitter_entity_id + ) + entry.async_on_unload(entry.add_update_listener(async_update_listener)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: KlikAanKlikUitConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_update_listener( + hass: HomeAssistant, entry: KlikAanKlikUitConfigEntry +) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/klik_aan_klik_uit/config_flow.py b/homeassistant/components/klik_aan_klik_uit/config_flow.py new file mode 100644 index 000000000000..e511c09b1e60 --- /dev/null +++ b/homeassistant/components/klik_aan_klik_uit/config_flow.py @@ -0,0 +1,187 @@ +"""Config flow for the KlikAanKlikUit RC integration.""" + +from typing import Any + +from rf_protocols.commands import ModulationType +from rf_protocols.commands.kaku import KakuCommand +import voluptuous as vol + +from homeassistant.components.radio_frequency import ( + async_get_transmitters, + async_send_command, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_DEVICE_ID +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er, selector + +from .const import ( + CONF_CHANNEL, + CONF_GROUP, + CONF_TRANSMITTER, + DOMAIN, + REPEAT_COUNT_LEARN, +) + +_SAMPLE_COMMAND = KakuCommand( + id=0, + channel=1, + group=False, + on=True, +) +_CONF_DEVICE_RESPONDED = "device_responded" + + +class KakuRcConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for KlikAanKlikUit.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize config flow.""" + self._device_data: dict[str, Any] | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle collecting initial setup data.""" + try: + transmitters = async_get_transmitters( + self.hass, + _SAMPLE_COMMAND.frequency, + ModulationType.OOK, + ) + except HomeAssistantError: + return self.async_abort(reason="no_transmitters") + + if not transmitters: + return self.async_abort(reason="no_compatible_transmitters") + + if user_input is not None: + transmitter: str = user_input[CONF_TRANSMITTER] + device_id: int = user_input[CONF_DEVICE_ID] + channel: int = user_input[CONF_CHANNEL] + group: bool = user_input[CONF_GROUP] + + registry = er.async_get(self.hass) + entity_entry = registry.async_get(transmitter) + assert entity_entry is not None + await self.async_set_unique_id( + f"{entity_entry.id}_{device_id}_{channel}_{int(group)}" + ) + self._abort_if_unique_id_configured() + self._device_data = { + CONF_TRANSMITTER: transmitter, + CONF_DEVICE_ID: device_id, + CONF_CHANNEL: channel, + CONF_GROUP: group, + } + return await self.async_step_pairing_mode() + + return self.async_show_form( + step_id="user", + data_schema=self._async_user_schema(transmitters), + ) + + async def async_step_pairing_mode( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask user to put the target device in pairing mode.""" + if user_input is None: + return self.async_show_form( + step_id="pairing_mode", + data_schema=vol.Schema({}), + ) + + assert self._device_data is not None + command = KakuCommand( + id=self._device_data[CONF_DEVICE_ID], + channel=self._device_data[CONF_CHANNEL], + group=self._device_data[CONF_GROUP], + on=True, + frame_repeats=REPEAT_COUNT_LEARN, + ) + await async_send_command( + self.hass, + self._device_data[CONF_TRANSMITTER], + command, + ) + return await self.async_step_pairing_result() + + async def async_step_pairing_result( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm whether the device responded to the learn command.""" + if user_input is not None: + if user_input[_CONF_DEVICE_RESPONDED]: + assert self._device_data is not None + title = ( + f"KlikAanKlikUit ID {self._device_data[CONF_DEVICE_ID]} " + f"CH {self._device_data[CONF_CHANNEL]}" + ) + return self.async_create_entry( + title=title, + data=self._device_data, + ) + + return await self.async_step_pairing_mode() + + return self.async_show_form( + step_id="pairing_result", + data_schema=vol.Schema( + { + vol.Required( + _CONF_DEVICE_RESPONDED, + default=False, + ): selector.BooleanSelector() + } + ), + ) + + def _async_user_schema( + self, + transmitters: list[str], + user_input: dict[str, Any] | None = None, + ) -> vol.Schema: + """Build the one-step add form schema.""" + if user_input is None: + user_input = {} + + suggested_values: dict[str, Any] = { + CONF_TRANSMITTER: transmitters[0], + CONF_CHANNEL: 1, + CONF_GROUP: False, + } + suggested_values.update(user_input) + + return self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_TRANSMITTER): selector.EntitySelector( + selector.EntitySelectorConfig(include_entities=transmitters), + ), + vol.Required(CONF_DEVICE_ID): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + max=0x3FFFFFF, + mode=selector.NumberSelectorMode.BOX, + ) + ), + vol.Coerce(int), + ), + vol.Required(CONF_CHANNEL): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, + max=16, + mode=selector.NumberSelectorMode.BOX, + ) + ), + vol.Coerce(int), + ), + vol.Required(CONF_GROUP): selector.BooleanSelector(), + } + ), + suggested_values, + ) diff --git a/homeassistant/components/klik_aan_klik_uit/const.py b/homeassistant/components/klik_aan_klik_uit/const.py new file mode 100644 index 000000000000..01a8a74a6f6e --- /dev/null +++ b/homeassistant/components/klik_aan_klik_uit/const.py @@ -0,0 +1,19 @@ +"""Constants and helpers for the KlikAanKlikUit (Kaku) integration.""" + +from typing import Final + +from homeassistant.const import CONF_DEVICE_ID as HA_CONF_DEVICE_ID + +DOMAIN: Final = "klik_aan_klik_uit" + +CONF_TRANSMITTER: Final = "transmitter" +CONF_DEVICE_ID: Final = HA_CONF_DEVICE_ID +CONF_CHANNEL: Final = "channel" +CONF_GROUP: Final = "group" +REPEAT_COUNT_LEARN: Final = 10 # Higher repeats for learning/pairing + + +def format_device_summary(device_id: int, channel: int, group: bool) -> str: + """Return a concise summary string for the configured device.""" + group_text = "on" if group else "off" + return f"ID {device_id} CH {channel} Group {group_text}" diff --git a/homeassistant/components/klik_aan_klik_uit/manifest.json b/homeassistant/components/klik_aan_klik_uit/manifest.json new file mode 100644 index 000000000000..3fdf275381c5 --- /dev/null +++ b/homeassistant/components/klik_aan_klik_uit/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "klik_aan_klik_uit", + "name": "KlikAanKlikUit", + "codeowners": ["@Phunkafizer"], + "config_flow": true, + "dependencies": ["radio_frequency"], + "documentation": "https://www.home-assistant.io/integrations/klik_aan_klik_uit", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "bronze" +} diff --git a/homeassistant/components/klik_aan_klik_uit/quality_scale.yaml b/homeassistant/components/klik_aan_klik_uit/quality_scale.yaml new file mode 100644 index 000000000000..a9f175eedc23 --- /dev/null +++ b/homeassistant/components/klik_aan_klik_uit/quality_scale.yaml @@ -0,0 +1,68 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: + status: exempt + comment: This integration uses local RF commands and has no account auth. + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: todo + entity-category: done + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: This integration does not use outbound web requests. + strict-typing: todo diff --git a/homeassistant/components/klik_aan_klik_uit/strings.json b/homeassistant/components/klik_aan_klik_uit/strings.json new file mode 100644 index 000000000000..14c367c6cfce --- /dev/null +++ b/homeassistant/components/klik_aan_klik_uit/strings.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_compatible_transmitters": "No compatible radio frequency transmitter is available for this integration.", + "no_transmitters": "[%key:common::config_flow::abort::no_radio_frequency_transmitters%]" + }, + "error": {}, + "step": { + "pairing_mode": { + "description": "Bring device into learn mode by pushing it's button for more than 2 seconds, then press Ok.", + "title": "Pair device" + }, + "pairing_result": { + "data": { + "device_responded": "Did the device respond?" + }, + "data_description": { + "device_responded": "Select Yes if the target device reacted to the learn command." + }, + "description": "Select Yes to continue setup. Select No to return to learn mode and resend the learn command.", + "title": "Confirm pairing" + }, + "user": { + "data": { + "channel": "Channel", + "device_id": "Device ID", + "group": "Group", + "transmitter": "[%key:common::config_flow::data::radio_frequency_transmitter%]" + }, + "data_description": { + "channel": "The channel of the target KlikAanKlikUit device (1-16).", + "device_id": "The unique KlikAanKlikUit device ID.", + "group": "Whether to send commands to the group address instead of a single device.", + "transmitter": "[%key:common::config_flow::data_description::radio_frequency_transmitter%]" + }, + "description": "Choose the transmitter and configure your device settings." + } + } + } +} diff --git a/homeassistant/components/klik_aan_klik_uit/switch.py b/homeassistant/components/klik_aan_klik_uit/switch.py new file mode 100644 index 000000000000..d9599e81bdfa --- /dev/null +++ b/homeassistant/components/klik_aan_klik_uit/switch.py @@ -0,0 +1,112 @@ +"""Switch platform for KlikAanKlikUit RC on/off control.""" + +from typing import Any + +from rf_protocols.commands.kaku import KakuCommand + +from homeassistant.components.radio_frequency import async_send_command +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import CONF_DEVICE_ID, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.restore_state import RestoreEntity + +from . import KlikAanKlikUitConfigEntry +from .const import CONF_CHANNEL, CONF_GROUP, DOMAIN, format_device_summary + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: KlikAanKlikUitConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the KlikAanKlikUit switch entity.""" + async_add_entities([KlikAanKlikUitSwitch(config_entry)]) + + +class KlikAanKlikUitSwitch(SwitchEntity, RestoreEntity): + """Switch entity for KlikAanKlikUit devices.""" + + _attr_has_entity_name = True + _attr_name = "Output" + _attr_should_poll = False + + def __init__(self, entry: KlikAanKlikUitConfigEntry) -> None: + """Initialize the switch.""" + self._transmitter = entry.runtime_data.transmitter_entity_id + self._device_id: int = entry.data[CONF_DEVICE_ID] + self._channel: int = entry.data[CONF_CHANNEL] + self._group: bool = entry.data[CONF_GROUP] + self._attr_unique_id = entry.entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="KlikAanKlikUit", + model="KlikAanKlikUit RC device", + sw_version=format_device_summary( + self._device_id, self._channel, self._group + ), + ) + + async def async_added_to_hass(self) -> None: + """Subscribe to transmitter state and restore last switch state.""" + await super().async_added_to_hass() + + transmitter_entity_id = er.async_validate_entity_id( + er.async_get(self.hass), self._transmitter + ) + + @callback + def _async_transmitter_state_changed( + event: Event[EventStateChangedData], + ) -> None: + new_state = event.data["new_state"] + available = new_state is not None and new_state.state != STATE_UNAVAILABLE + if available != self._attr_available: + self._attr_available = available + self.async_write_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, + [transmitter_entity_id], + _async_transmitter_state_changed, + ) + ) + + transmitter_state = self.hass.states.get(transmitter_entity_id) + self._attr_available = ( + transmitter_state is not None + and transmitter_state.state != STATE_UNAVAILABLE + ) + + if (last_state := await self.async_get_last_state()) is not None: + self._attr_is_on = last_state.state == STATE_ON + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self._async_send(True) + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self._async_send(False) + self._attr_is_on = False + self.async_write_ha_state() + + async def _async_send(self, on: bool) -> None: + """Send on/off command.""" + command = KakuCommand( + id=self._device_id, + group=self._group, + channel=self._channel, + on=on, + ) + await async_send_command( + self.hass, self._transmitter, command, context=self._context + ) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c017d0a1072e..cc3f0f8d6ea7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -386,6 +386,7 @@ FLOWS = { "kegtron", "keymitt_ble", "kiosker", + "klik_aan_klik_uit", "kmtronic", "knocki", "knx", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7303e333ccc4..3cb76d9be1a8 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3581,6 +3581,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "klik_aan_klik_uit": { + "name": "KlikAanKlikUit", + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state" + }, "kmtronic": { "name": "KMtronic", "integration_type": "device", diff --git a/tests/components/klik_aan_klik_uit/__init__.py b/tests/components/klik_aan_klik_uit/__init__.py new file mode 100644 index 000000000000..80c6c61c148e --- /dev/null +++ b/tests/components/klik_aan_klik_uit/__init__.py @@ -0,0 +1 @@ +"""Tests for the Kaku RC integration.""" diff --git a/tests/components/klik_aan_klik_uit/conftest.py b/tests/components/klik_aan_klik_uit/conftest.py new file mode 100644 index 000000000000..5091d5c252a3 --- /dev/null +++ b/tests/components/klik_aan_klik_uit/conftest.py @@ -0,0 +1,52 @@ +"""Common fixtures for Kaku RC tests.""" + +import pytest + +from homeassistant.components.klik_aan_klik_uit.const import ( + CONF_CHANNEL, + CONF_DEVICE_ID, + CONF_GROUP, + CONF_TRANSMITTER, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.radio_frequency.common import MockRadioFrequencyEntity + +TRANSMITTER_ENTITY_ID = "radio_frequency.test_rf_transmitter" + + +@pytest.fixture +def mock_config_entry( + mock_rf_entity: MockRadioFrequencyEntity, + entity_registry: er.EntityRegistry, +) -> MockConfigEntry: + """Return a mock config entry for Kaku RC setup.""" + entity_entry = entity_registry.async_get(TRANSMITTER_ENTITY_ID) + assert entity_entry is not None + + return MockConfigEntry( + domain=DOMAIN, + title="Kaku ID 123456 CH 1", + data={ + CONF_TRANSMITTER: TRANSMITTER_ENTITY_ID, + CONF_DEVICE_ID: 123456, + CONF_CHANNEL: 1, + CONF_GROUP: False, + }, + unique_id=f"{entity_entry.id}_123456_1_0", + ) + + +@pytest.fixture +async def init_klik_aan_klik_uit( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Set up Kaku RC integration.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry diff --git a/tests/components/klik_aan_klik_uit/test_config_flow.py b/tests/components/klik_aan_klik_uit/test_config_flow.py new file mode 100644 index 000000000000..4e3ca5199348 --- /dev/null +++ b/tests/components/klik_aan_klik_uit/test_config_flow.py @@ -0,0 +1,152 @@ +"""Test the Kaku RC config flow.""" + +from homeassistant.components.klik_aan_klik_uit.const import ( + CONF_CHANNEL, + CONF_DEVICE_ID, + CONF_GROUP, + CONF_TRANSMITTER, + DOMAIN, +) +from homeassistant.components.radio_frequency import DATA_COMPONENT, DOMAIN as RF_DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.setup import async_setup_component + +from .conftest import TRANSMITTER_ENTITY_ID + +from tests.common import MockConfigEntry +from tests.components.radio_frequency.common import MockRadioFrequencyEntity + + +async def _start_user_flow(hass: HomeAssistant) -> dict: + """Start user flow and assert first form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + return result + + +async def test_user_flow( + hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity +) -> None: + """Test successful user flow creates an entry.""" + result = await _start_user_flow(hass) + + user_input = { + CONF_TRANSMITTER: TRANSMITTER_ENTITY_ID, + CONF_DEVICE_ID: 123456, + CONF_CHANNEL: 1, + CONF_GROUP: False, + } + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=user_input + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pairing_mode" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert len(mock_rf_entity.send_command_calls) == 1 + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pairing_result" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"device_responded": True} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "KlikAanKlikUit ID 123456 CH 1" + assert result["data"] == user_input + + +async def test_user_flow_retry_learn( + hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity +) -> None: + """Test the user can retry pairing when the device does not respond.""" + result = await _start_user_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TRANSMITTER: TRANSMITTER_ENTITY_ID, + CONF_DEVICE_ID: 123456, + CONF_CHANNEL: 1, + CONF_GROUP: False, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pairing_mode" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert len(mock_rf_entity.send_command_calls) == 1 + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pairing_result" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"device_responded": False} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pairing_mode" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert len(mock_rf_entity.send_command_calls) == 2 + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pairing_result" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"device_responded": True} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_unique_id_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test aborting when same transmitter/id/channel/group is configured.""" + mock_config_entry.add_to_hass(hass) + + result = await _start_user_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TRANSMITTER: TRANSMITTER_ENTITY_ID, + CONF_DEVICE_ID: 123456, + CONF_CHANNEL: 1, + CONF_GROUP: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_no_transmitters(hass: HomeAssistant) -> None: + """Test flow aborts when no RF transmitters are set up.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_transmitters" + + +async def test_no_compatible_transmitters(hass: HomeAssistant) -> None: + """Test aborting when transmitters exist but none support 433.92 MHz OOK.""" + assert await async_setup_component(hass, RF_DOMAIN, {}) + await hass.async_block_till_done() + incompatible = MockRadioFrequencyEntity( + "incompatible", frequency_ranges=[(868_000_000, 869_000_000)] + ) + await hass.data[DATA_COMPONENT].async_add_entities([incompatible]) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_compatible_transmitters" diff --git a/tests/components/klik_aan_klik_uit/test_switch.py b/tests/components/klik_aan_klik_uit/test_switch.py new file mode 100644 index 000000000000..30da44a2121a --- /dev/null +++ b/tests/components/klik_aan_klik_uit/test_switch.py @@ -0,0 +1,57 @@ +"""Tests for the Kaku RC switch platform.""" + +from rf_protocols.commands.kaku import _DEFAULT_REPEATS + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.radio_frequency.common import MockRadioFrequencyEntity + +SWITCH_ENTITY_ID = "switch.kaku_id_123456_ch_1_output" + + +async def test_turn_on_off_sends_kaku_commands( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + init_klik_aan_klik_uit: MockConfigEntry, +) -> None: + """Test non-dim switch on/off behavior.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(SWITCH_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + assert len(mock_rf_entity.send_command_calls) == 1 + + first_command = mock_rf_entity.send_command_calls[0].command + assert first_command.on is True + assert first_command.dimlevel is None + assert first_command.repeat_count == _DEFAULT_REPEATS + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(SWITCH_ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + assert len(mock_rf_entity.send_command_calls) == 2 + + second_command = mock_rf_entity.send_command_calls[1].command + assert second_command.on is False + assert second_command.dimlevel is None + assert second_command.repeat_count == _DEFAULT_REPEATS From 08f4774e6497bec6d4a0e4662f20f695d7db6b84 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 06:14:51 +0200 Subject: [PATCH 091/404] Update uv to 0.11.19 (#173483) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e506ab947661..ee45692f235a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -70,7 +70,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.11.18 +uv==0.11.19 voluptuous-openapi==0.3.0 voluptuous-serialize==2.7.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index d15219caf22b..c01f4a99eb4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "typing-extensions>=4.15.0,<5.0", "ulid-transform==2.2.9", "urllib3>=2.0", - "uv==0.11.18", + "uv==0.11.19", "voluptuous==0.15.2", "voluptuous-serialize==2.7.0", "voluptuous-openapi==0.3.0", diff --git a/requirements.txt b/requirements.txt index bbc5e3d1cfa7..2ff9f4188030 100644 --- a/requirements.txt +++ b/requirements.txt @@ -55,7 +55,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.11.18 +uv==0.11.19 voluptuous-openapi==0.3.0 voluptuous-serialize==2.7.0 voluptuous==0.15.2 From 1126e89d325b216af439b1a9f6eac8056cb44e2b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 06:17:00 +0200 Subject: [PATCH 092/404] Update hassil to 3.7.0 (#173484) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- homeassistant/components/assist_satellite/manifest.json | 2 +- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/assist_satellite/manifest.json b/homeassistant/components/assist_satellite/manifest.json index 5ce409a3ea8a..9679b92325cc 100644 --- a/homeassistant/components/assist_satellite/manifest.json +++ b/homeassistant/components/assist_satellite/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/assist_satellite", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["hassil==3.6.0"] + "requirements": ["hassil==3.7.0"] } diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 0945e52b2c14..d250f452b558 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["hassil==3.6.0", "home-assistant-intents==2026.6.1"] + "requirements": ["hassil==3.7.0", "home-assistant-intents==2026.6.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ee45692f235a..9101470689ce 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ go2rtc-client==0.4.0 ha-ffmpeg==3.2.2 habluetooth==6.8.3 hass-nabucasa==2.2.0 -hassil==3.6.0 +hassil==3.7.0 home-assistant-bluetooth==2.0.0 home-assistant-frontend==20260527.5 home-assistant-intents==2026.6.1 diff --git a/requirements.txt b/requirements.txt index 2ff9f4188030..fb9d37312f3b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ cryptography==48.0.0 fnv-hash-fast==2.0.3 ha-ffmpeg==3.2.2 hass-nabucasa==2.2.0 -hassil==3.6.0 +hassil==3.7.0 home-assistant-bluetooth==2.0.0 home-assistant-intents==2026.6.1 httpx==0.28.1 diff --git a/requirements_all.txt b/requirements_all.txt index 1f6d4db98e62..34fb62d680f0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1229,7 +1229,7 @@ hass-splunk==0.1.4 # homeassistant.components.assist_satellite # homeassistant.components.conversation -hassil==3.6.0 +hassil==3.7.0 # homeassistant.components.jewish_calendar hdate[astral]==1.2.1 From 2e4185840af625e168d7f6ca8bfe3fdbca25d5cf Mon Sep 17 00:00:00 2001 From: James Myatt Date: Thu, 11 Jun 2026 05:43:53 +0100 Subject: [PATCH 093/404] Improve todo tests (#173454) --- tests/components/todo/conftest.py | 19 +++++++++++++--- tests/components/todo/test_init.py | 35 +++++++++++++++++------------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/tests/components/todo/conftest.py b/tests/components/todo/conftest.py index 5742f2537496..70c428fea1c0 100644 --- a/tests/components/todo/conftest.py +++ b/tests/components/todo/conftest.py @@ -1,7 +1,9 @@ """Fixtures for the todo component tests.""" from collections.abc import Generator +import datetime from unittest.mock import AsyncMock +import zoneinfo import pytest @@ -19,6 +21,8 @@ from . import TEST_DOMAIN, MockFlow, MockTodoListEntity from tests.common import MockModule, mock_config_flow, mock_integration, mock_platform +TEST_TIMEZONE = zoneinfo.ZoneInfo("America/Regina") + @pytest.fixture(autouse=True) def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: @@ -62,7 +66,7 @@ def mock_setup_integration(hass: HomeAssistant) -> None: @pytest.fixture(autouse=True) async def set_time_zone(hass: HomeAssistant) -> None: - """Set the time zone for the tests that keesp UTC-6 all year round.""" + """Set the time zone for the tests that keeps UTC-6 all year round.""" await hass.config.async_set_time_zone("America/Regina") @@ -70,8 +74,17 @@ async def set_time_zone(hass: HomeAssistant) -> None: def mock_test_entity_items() -> list[TodoItem]: """Fixture that creates the items returned by the test entity.""" return [ - TodoItem(summary="Item #1", uid="1", status=TodoItemStatus.NEEDS_ACTION), - TodoItem(summary="Item #2", uid="2", status=TodoItemStatus.COMPLETED), + TodoItem( + summary="Item #1", + uid="1", + status=TodoItemStatus.NEEDS_ACTION, + ), + TodoItem( + summary="Item #2", + uid="2", + status=TodoItemStatus.COMPLETED, + completed=datetime.datetime(2026, 3, 27, 11, 0, 0, tzinfo=TEST_TIMEZONE), + ), ] diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index e7a9fd364f98..d11114240b9d 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -35,6 +35,9 @@ from . import create_mock_platform from tests.typing import WebSocketGenerator +TEST_TIMEZONE = zoneinfo.ZoneInfo("America/Regina") +TEST_OFFSET = "-06:00" + ITEM_1 = { "uid": "1", "summary": "Item #1", @@ -44,9 +47,8 @@ ITEM_2 = { "uid": "2", "summary": "Item #2", "status": "completed", + "completed": f"2026-03-27T11:00:00{TEST_OFFSET}", } -TEST_TIMEZONE = zoneinfo.ZoneInfo("America/Regina") -TEST_OFFSET = "-06:00" async def test_unload_entry( @@ -81,7 +83,7 @@ async def test_list_todo_items( state = hass.states.get("todo.entity1") assert state assert state.state == "1" - assert state.attributes == {"supported_features": 15} + assert state.attributes == {ATTR_SUPPORTED_FEATURES: 15} client = await hass_ws_client(hass) await client.send_json( @@ -324,6 +326,7 @@ async def test_add_item_service_extended_fields( ) -> None: """Test adding an item in a To-do list.""" + assert test_entity._attr_supported_features is not None test_entity._attr_supported_features |= supported_entity_feature await create_mock_platform(hass, [test_entity]) @@ -555,9 +558,9 @@ async def test_update_item_service_invalid_input( @pytest.mark.parametrize( ("update_data"), [ - ({"due_datetime": f"2023-11-13T17:00:00{TEST_OFFSET}"}), - ({"due_date": "2023-11-13"}), - ({"description": "Submit revised draft"}), + ({ATTR_DUE_DATETIME: f"2023-11-13T17:00:00{TEST_OFFSET}"}), + ({ATTR_DUE_DATE: "2023-11-13"}), + ({ATTR_DESCRIPTION: "Submit revised draft"}), ], ) async def test_update_todo_item_field_unsupported( @@ -623,6 +626,7 @@ async def test_update_todo_item_extended_fields( ) -> None: """Test updating an item in a To-do list.""" + assert test_entity._attr_supported_features is not None test_entity._attr_supported_features |= supported_entity_feature await create_mock_platform(hass, [test_entity]) @@ -645,32 +649,32 @@ async def test_update_todo_item_extended_fields( [ ( [TodoItem(uid="1", summary="Summary", description="description")], - {"description": "Submit revised draft"}, + {ATTR_DESCRIPTION: "Submit revised draft"}, TodoItem(uid="1", summary="Summary", description="Submit revised draft"), ), ( [TodoItem(uid="1", summary="Summary", description="description")], - {"description": ""}, + {ATTR_DESCRIPTION: ""}, TodoItem(uid="1", summary="Summary", description=""), ), ( [TodoItem(uid="1", summary="Summary", description="description")], - {"description": None}, + {ATTR_DESCRIPTION: None}, TodoItem(uid="1", summary="Summary"), ), ( [TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 1))], - {"due_date": datetime.date(2024, 1, 2)}, + {ATTR_DUE_DATE: datetime.date(2024, 1, 2)}, TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 2)), ), ( [TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 1))], - {"due_date": None}, + {ATTR_DUE_DATE: None}, TodoItem(uid="1", summary="Summary"), ), ( [TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 1))], - {"due_datetime": datetime.datetime(2024, 1, 1, 10, 0, 0)}, + {ATTR_DUE_DATETIME: datetime.datetime(2024, 1, 1, 10, 0, 0)}, TodoItem( uid="1", summary="Summary", @@ -687,7 +691,7 @@ async def test_update_todo_item_extended_fields( due=datetime.datetime(2024, 1, 1, 10, 0, 0), ) ], - {"due_datetime": None}, + {ATTR_DUE_DATETIME: None}, TodoItem(uid="1", summary="Summary"), ), ], @@ -709,6 +713,7 @@ async def test_update_todo_item_extended_fields_overwrite_existing_values( ) -> None: """Test updating an item in a To-do list.""" + assert test_entity._attr_supported_features is not None test_entity._attr_supported_features |= ( TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM @@ -1094,7 +1099,7 @@ async def test_subscribe( "status": "completed", "due": None, "description": None, - "completed": None, + "completed": f"2026-03-27T11:00:00{TEST_OFFSET}", }, ] } @@ -1122,7 +1127,7 @@ async def test_subscribe( "status": "completed", "due": None, "description": None, - "completed": None, + "completed": f"2026-03-27T11:00:00{TEST_OFFSET}", }, { "summary": "Item #3", From fd21674ca13d78523627d86b3e65970c586eb1c0 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 11 Jun 2026 07:45:31 +0200 Subject: [PATCH 094/404] Add MELCloud Home integration (#173185) --- CODEOWNERS | 2 + .../components/melcloud_home/__init__.py | 38 ++ .../components/melcloud_home/climate.py | 372 ++++++++++++++++++ .../components/melcloud_home/config_flow.py | 94 +++++ .../components/melcloud_home/const.py | 3 + .../components/melcloud_home/coordinator.py | 114 ++++++ .../components/melcloud_home/entity.py | 84 ++++ .../components/melcloud_home/manifest.json | 12 + .../melcloud_home/quality_scale.yaml | 68 ++++ .../components/melcloud_home/strings.json | 77 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + tests/components/melcloud_home/__init__.py | 15 + tests/components/melcloud_home/conftest.py | 48 +++ .../melcloud_home/fixtures/context.json | 202 ++++++++++ .../melcloud_home/snapshots/test_climate.ambr | 266 +++++++++++++ .../components/melcloud_home/test_climate.py | 362 +++++++++++++++++ .../melcloud_home/test_config_flow.py | 108 +++++ tests/components/melcloud_home/test_init.py | 138 +++++++ 20 files changed, 2013 insertions(+) create mode 100644 homeassistant/components/melcloud_home/__init__.py create mode 100644 homeassistant/components/melcloud_home/climate.py create mode 100644 homeassistant/components/melcloud_home/config_flow.py create mode 100644 homeassistant/components/melcloud_home/const.py create mode 100644 homeassistant/components/melcloud_home/coordinator.py create mode 100644 homeassistant/components/melcloud_home/entity.py create mode 100644 homeassistant/components/melcloud_home/manifest.json create mode 100644 homeassistant/components/melcloud_home/quality_scale.yaml create mode 100644 homeassistant/components/melcloud_home/strings.json create mode 100644 tests/components/melcloud_home/__init__.py create mode 100644 tests/components/melcloud_home/conftest.py create mode 100644 tests/components/melcloud_home/fixtures/context.json create mode 100644 tests/components/melcloud_home/snapshots/test_climate.ambr create mode 100644 tests/components/melcloud_home/test_climate.py create mode 100644 tests/components/melcloud_home/test_config_flow.py create mode 100644 tests/components/melcloud_home/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 6a0b12c04907..bf805f67abc8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1086,6 +1086,8 @@ CLAUDE.md @home-assistant/core /homeassistant/components/mediaroom/ @dgomes /homeassistant/components/melcloud/ @erwindouna /tests/components/melcloud/ @erwindouna +/homeassistant/components/melcloud_home/ @erwindouna +/tests/components/melcloud_home/ @erwindouna /homeassistant/components/melissa/ @kennedyshead /tests/components/melissa/ @kennedyshead /homeassistant/components/melnor/ @vanstinator diff --git a/homeassistant/components/melcloud_home/__init__.py b/homeassistant/components/melcloud_home/__init__.py new file mode 100644 index 000000000000..5ea364182d61 --- /dev/null +++ b/homeassistant/components/melcloud_home/__init__.py @@ -0,0 +1,38 @@ +"""The MELCloud Home integration.""" + +from aiomelcloudhome import MELCloudHome, MelCloudHomeAuth + +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry( + hass: HomeAssistant, entry: MelCloudHomeConfigEntry +) -> bool: + """Set up MELCloud Home from a config entry.""" + session = async_get_clientsession(hass) + auth = MelCloudHomeAuth( + username=entry.data[CONF_EMAIL], + password=entry.data[CONF_PASSWORD], + session=session, + ) + client = MELCloudHome(auth=auth, session=session) + + coordinator = MelCloudHomeCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: MelCloudHomeConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/melcloud_home/climate.py b/homeassistant/components/melcloud_home/climate.py new file mode 100644 index 000000000000..2b1b0871a1e4 --- /dev/null +++ b/homeassistant/components/melcloud_home/climate.py @@ -0,0 +1,372 @@ +"""Climate platform for MELCloud Home.""" + +from typing import Any + +from aiomelcloudhome import ( + ATAFanSpeed, + ATAOperationMode, + ATAUnit, + ATAVaneHorizontal, + ATAVaneVertical, + ATWUnit, + ATWZoneMode, +) + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator +from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWZoneEntity + +ATA_HVAC_MODE_TO_OPERATION: dict[HVACMode, ATAOperationMode] = { + HVACMode.HEAT: ATAOperationMode.HEAT, + HVACMode.COOL: ATAOperationMode.COOL, + HVACMode.AUTO: ATAOperationMode.AUTOMATIC, + HVACMode.DRY: ATAOperationMode.DRY, + HVACMode.FAN_ONLY: ATAOperationMode.FAN, +} + +ATA_OPERATION_TO_HVAC_MODE: dict[ATAOperationMode, HVACMode] = { + value: key for key, value in ATA_HVAC_MODE_TO_OPERATION.items() +} + +ATA_FAN_SPEED_TO_HA: dict[ATAFanSpeed, str] = { + ATAFanSpeed.AUTO: "auto", + ATAFanSpeed.ONE: "speed_1", + ATAFanSpeed.TWO: "speed_2", + ATAFanSpeed.THREE: "speed_3", + ATAFanSpeed.FOUR: "speed_4", + ATAFanSpeed.FIVE: "speed_5", +} + +HA_FAN_SPEED_TO_ATA: dict[str, ATAFanSpeed] = { + value: key for key, value in ATA_FAN_SPEED_TO_HA.items() +} + +ATA_VANE_VERTICAL_TO_HA: dict[ATAVaneVertical, str] = { + ATAVaneVertical.AUTO: "auto", + ATAVaneVertical.SWING: "swing", + ATAVaneVertical.ONE: "position_1", + ATAVaneVertical.TWO: "position_2", + ATAVaneVertical.THREE: "position_3", + ATAVaneVertical.FOUR: "position_4", + ATAVaneVertical.FIVE: "position_5", +} + +HA_VANE_VERTICAL_TO_ATA: dict[str, ATAVaneVertical] = { + value: key for key, value in ATA_VANE_VERTICAL_TO_HA.items() +} + +ATA_VANE_HORIZONTAL_TO_HA: dict[ATAVaneHorizontal, str] = { + ATAVaneHorizontal.AUTO: "auto", + ATAVaneHorizontal.SWING: "swing", + ATAVaneHorizontal.LEFT: "left", + ATAVaneHorizontal.LEFT_CENTRE: "left_centre", + ATAVaneHorizontal.CENTRE: "centre", + ATAVaneHorizontal.RIGHT_CENTRE: "right_centre", + ATAVaneHorizontal.RIGHT: "right", +} + +HA_VANE_HORIZONTAL_TO_ATA: dict[str, ATAVaneHorizontal] = { + value: key for key, value in ATA_VANE_HORIZONTAL_TO_HA.items() +} + +ATW_ZONE_MODE_TO_HVAC_MODE: dict[ATWZoneMode, HVACMode] = { + ATWZoneMode.HEAT_ROOM_TEMPERATURE: HVACMode.HEAT, + ATWZoneMode.HEAT_FLOW_TEMPERATURE: HVACMode.HEAT, + ATWZoneMode.HEAT_CURVE: HVACMode.HEAT, + ATWZoneMode.COOL_ROOM_TEMPERATURE: HVACMode.COOL, + ATWZoneMode.COOL_FLOW_TEMPERATURE: HVACMode.COOL, +} + +HVAC_MODE_TO_ATW_ZONE_MODE: dict[HVACMode, ATWZoneMode] = { + HVACMode.HEAT: ATWZoneMode.HEAT_ROOM_TEMPERATURE, + HVACMode.COOL: ATWZoneMode.COOL_ROOM_TEMPERATURE, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MelCloudHomeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up MELCloud Home climate entities from a config entry.""" + coordinator = entry.runtime_data + + def _async_add_new_ata_units(units: list[ATAUnit]) -> None: + async_add_entities(ATAClimateEntity(coordinator, unit) for unit in units) + + def _async_add_new_atw_units(units: list[ATWUnit]) -> None: + # Erwin: create zone 1 for all units, and zone 2 only when the unit supports it. + async_add_entities( + ATWZoneClimateEntity(coordinator, unit, zone_number) + for unit in units + for zone_number in ( + [1, 2] + if (unit.capabilities and unit.capabilities.has_zone2) + or (unit.capabilities is None and unit.has_zone2) + else [1] + ) + ) + + coordinator.new_ata_callbacks.append(_async_add_new_ata_units) + coordinator.new_atw_callbacks.append(_async_add_new_atw_units) + + _async_add_new_ata_units(list(coordinator.ata_units.values())) + _async_add_new_atw_units(list(coordinator.atw_units.values())) + + +class ATAClimateEntity(MelCloudHomeATAUnitEntity, ClimateEntity): + """Climate entity for a MELCloud Home Air-to-Air unit.""" + + _attr_translation_key = "ata_unit" + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_swing_modes = list(ATA_VANE_VERTICAL_TO_HA.values()) + _attr_swing_horizontal_modes = list(ATA_VANE_HORIZONTAL_TO_HA.values()) + + def __init__(self, coordinator: MelCloudHomeCoordinator, unit: ATAUnit) -> None: + """Initialize the entity.""" + super().__init__(coordinator, unit) + features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + if unit.settings is not None: + if unit.settings.get("VaneVerticalDirection") is not None: + features |= ClimateEntityFeature.SWING_MODE + if unit.settings.get("VaneHorizontalDirection") is not None: + features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE + self._attr_supported_features = features + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return HVAC modes supported by this unit based on its capabilities.""" + if self.unit.capabilities is None: + return [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.AUTO, + HVACMode.DRY, + HVACMode.FAN_ONLY, + ] + + modes = [HVACMode.OFF, HVACMode.HEAT] + if self.unit.capabilities.has_cool_operation_mode is not False: + modes.append(HVACMode.COOL) + if self.unit.capabilities.has_auto_operation_mode is not False: + modes.append(HVACMode.AUTO) + if self.unit.capabilities.has_dry_operation_mode is not False: + modes.append(HVACMode.DRY) + if self.unit.capabilities.has_fan_operation_mode is not False: + modes.append(HVACMode.FAN_ONLY) + return modes + + @property + def fan_modes(self) -> list[str]: + """Return fan modes supported by this unit based on its capabilities.""" + capabilities = self.unit.capabilities + number = ( + capabilities.number_of_fan_speeds + if capabilities is not None + and capabilities.number_of_fan_speeds is not None + else len(ATA_FAN_SPEED_TO_HA) - 1 + ) + all_speeds = list(ATA_FAN_SPEED_TO_HA.values()) + return [all_speeds[0], *all_speeds[1 : number + 1]] + + @property + def current_temperature(self) -> float | None: + """Return the current room temperature.""" + return self.unit.room_temperature if self.unit else None + + @property + def target_temperature(self) -> float | None: + """Return the target temperature.""" + return self.unit.set_temperature if self.unit else None + + @property + def hvac_mode(self) -> HVACMode: + """Return the current HVAC mode.""" + return ( + ATA_OPERATION_TO_HVAC_MODE.get(self.unit.operation_mode, HVACMode.OFF) + if self.unit.power and self.unit.operation_mode + else HVACMode.OFF + ) + + @property + def fan_mode(self) -> str | None: + """Return the current fan mode.""" + return ( + ATA_FAN_SPEED_TO_HA.get(self.unit.set_fan_speed) + if self.unit.set_fan_speed is not None + else None + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the HVAC mode.""" + if hvac_mode == HVACMode.OFF: + await self.coordinator.client.control_ata_unit(self._unit_id, power=False) + else: + await self.coordinator.client.control_ata_unit( + self._unit_id, + power=True, + operation_mode=ATA_HVAC_MODE_TO_OPERATION[hvac_mode], + ) + await self.coordinator.async_request_refresh() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the target temperature.""" + await self.coordinator.client.control_ata_unit( + self._unit_id, set_temperature=kwargs[ATTR_TEMPERATURE] + ) + await self.coordinator.async_request_refresh() + + @property + def swing_mode(self) -> str: + """Return the current vertical vane direction.""" + return ATA_VANE_VERTICAL_TO_HA[self.unit.settings["VaneVerticalDirection"]] + + @property + def swing_horizontal_mode(self) -> str: + """Return the current horizontal vane direction.""" + return ATA_VANE_HORIZONTAL_TO_HA[self.unit.settings["VaneHorizontalDirection"]] + + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set the horizontal vane direction.""" + await self.coordinator.client.control_ata_unit( + self._unit_id, + vane_horizontal_direction=HA_VANE_HORIZONTAL_TO_ATA[swing_horizontal_mode], + ) + await self.coordinator.async_request_refresh() + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set the vertical vane direction.""" + await self.coordinator.client.control_ata_unit( + self._unit_id, vane_vertical_direction=HA_VANE_VERTICAL_TO_ATA[swing_mode] + ) + await self.coordinator.async_request_refresh() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set the fan mode.""" + await self.coordinator.client.control_ata_unit( + self._unit_id, set_fan_speed=HA_FAN_SPEED_TO_ATA[fan_mode] + ) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self) -> None: + """Turn the unit on.""" + await self.coordinator.client.control_ata_unit(self._unit_id, power=True) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self) -> None: + """Turn the unit off.""" + await self.coordinator.client.control_ata_unit(self._unit_id, power=False) + await self.coordinator.async_request_refresh() + + +class ATWZoneClimateEntity(MelCloudHomeATWZoneEntity, ClimateEntity): + """Climate entity for a MELCloud Home ATW zone.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return HVAC modes supported by this zone based on unit capabilities.""" + modes = [HVACMode.OFF, HVACMode.HEAT] + if ( + self.unit.capabilities is None + or self.unit.capabilities.has_cooling_mode is not False + ): + modes.append(HVACMode.COOL) + return modes + + @property + def _zone_mode(self) -> ATWZoneMode | None: + """Return the current ATW zone mode.""" + if self.zone_number == 1: + return self.unit.operation_mode_zone1 + return self.unit.operation_mode_zone2 + + @property + def current_temperature(self) -> float | None: + """Return the current zone temperature.""" + return ( + self.unit.room_temperature_zone1 + if self.zone_number == 1 + else self.unit.room_temperature_zone2 + ) + + @property + def target_temperature(self) -> float | None: + """Return the target zone temperature.""" + return ( + self.unit.set_temperature_zone1 + if self.zone_number == 1 + else self.unit.set_temperature_zone2 + ) + + @property + def hvac_mode(self) -> HVACMode: + """Return the current HVAC mode.""" + return ( + ATW_ZONE_MODE_TO_HVAC_MODE.get(self._zone_mode, HVACMode.OFF) + if self.unit.power and self._zone_mode + else HVACMode.OFF + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the HVAC mode.""" + if hvac_mode == HVACMode.OFF: + await self.coordinator.client.control_atw_unit(self._unit_id, power=False) + else: + zone_mode = HVAC_MODE_TO_ATW_ZONE_MODE[hvac_mode] + if self.zone_number == 1: + await self.coordinator.client.control_atw_unit( + self._unit_id, + power=True, + operation_mode_zone1=zone_mode, + ) + else: + await self.coordinator.client.control_atw_unit( + self._unit_id, + power=True, + operation_mode_zone2=zone_mode, + ) + await self.coordinator.async_request_refresh() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the target temperature.""" + temperature = kwargs[ATTR_TEMPERATURE] + if self.zone_number == 1: + await self.coordinator.client.control_atw_unit( + self._unit_id, set_temperature_zone1=temperature + ) + else: + await self.coordinator.client.control_atw_unit( + self._unit_id, set_temperature_zone2=temperature + ) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self) -> None: + """Turn the zone on.""" + await self.coordinator.client.control_atw_unit(self._unit_id, power=True) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self) -> None: + """Turn the zone off.""" + await self.coordinator.client.control_atw_unit(self._unit_id, power=False) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/melcloud_home/config_flow.py b/homeassistant/components/melcloud_home/config_flow.py new file mode 100644 index 000000000000..16f25405a58c --- /dev/null +++ b/homeassistant/components/melcloud_home/config_flow.py @@ -0,0 +1,94 @@ +"""Config flow for MELCloud Home.""" + +import logging +from typing import Any + +from aiomelcloudhome import MELCloudHome, MelCloudHomeAuth +from aiomelcloudhome.exceptions import ( + MelCloudHomeAuthenticationError, + MelCloudHomeConnectionError, + MelCloudHomeTimeoutError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } +) + + +class MelCloudHomeConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for MELCloud Home.""" + + async def _async_validate_credentials( + self, email: str, password: str + ) -> tuple[dict[str, str], str | None]: + """Validate credentials against MELCloud Home API.""" + session = async_get_clientsession(self.hass) + auth = MelCloudHomeAuth(username=email, password=password, session=session) + client = MELCloudHome(auth=auth, session=session) + + errors: dict[str, str] = {} + user_id: str | None = None + + try: + context = await client.get_context() + except MelCloudHomeAuthenticationError: + errors["base"] = "invalid_auth" + except MelCloudHomeConnectionError: + errors["base"] = "cannot_connect" + except MelCloudHomeTimeoutError: + errors["base"] = "timeout_connect" + except Exception: + _LOGGER.exception( + "Unexpected error while validating MELCloud Home credentials" + ) + errors["base"] = "unknown" + else: + user_id = context.id + + return errors, user_id + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + errors, user_id = await self._async_validate_credentials( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + if not errors: + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_EMAIL], + data={ + CONF_EMAIL: user_input[CONF_EMAIL], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/melcloud_home/const.py b/homeassistant/components/melcloud_home/const.py new file mode 100644 index 000000000000..6ce91d654e5f --- /dev/null +++ b/homeassistant/components/melcloud_home/const.py @@ -0,0 +1,3 @@ +"""Constants for the MELCloud Home integration.""" + +DOMAIN = "melcloud_home" diff --git a/homeassistant/components/melcloud_home/coordinator.py b/homeassistant/components/melcloud_home/coordinator.py new file mode 100644 index 000000000000..a055c731821a --- /dev/null +++ b/homeassistant/components/melcloud_home/coordinator.py @@ -0,0 +1,114 @@ +"""Coordinator for MELCloud Home.""" + +from collections.abc import Callable +from datetime import timedelta +import logging + +from aiomelcloudhome import ATAUnit, ATWUnit, MELCloudHome, UserContext +from aiomelcloudhome.exceptions import ( + MelCloudHomeAuthenticationError, + MelCloudHomeConnectionError, + MelCloudHomeTimeoutError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = timedelta(seconds=60) + + +type MelCloudHomeConfigEntry = ConfigEntry[MelCloudHomeCoordinator] + + +class MelCloudHomeCoordinator(DataUpdateCoordinator[UserContext]): + """Coordinator to manage fetching MELCloud Home data.""" + + config_entry: MelCloudHomeConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: MelCloudHomeConfigEntry, + client: MELCloudHome, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) + self.client = client + self.ata_units: dict[str, ATAUnit] = {} + self.atw_units: dict[str, ATWUnit] = {} + self.known_ata: set[str] = set() + self.known_atw: set[str] = set() + self.new_ata_callbacks: list[Callable[[list[ATAUnit]], None]] = [] + self.new_atw_callbacks: list[Callable[[list[ATWUnit]], None]] = [] + + def _notify_new_units(self, data: UserContext) -> None: + """Notify callbacks when new units are discovered.""" + current_ata = [ + unit for building in data.buildings for unit in building.air_to_air_units + ] + self.ata_units = {unit.id: unit for unit in current_ata} + current_ata_ids = {unit.id for unit in current_ata} + self.known_ata &= current_ata_ids + new_ata_ids = current_ata_ids - self.known_ata + new_ata_units = [unit for unit in current_ata if unit.id in new_ata_ids] + if new_ata_units: + _LOGGER.debug("Discovered new ATA units: %s", new_ata_units) + self.known_ata.update(unit.id for unit in new_ata_units) + for ata_callback in self.new_ata_callbacks: + ata_callback(new_ata_units) + + current_atw_units = [ + unit for building in data.buildings for unit in building.air_to_water_units + ] + self.atw_units = {unit.id: unit for unit in current_atw_units} + current_atw_ids = {unit.id for unit in current_atw_units} + self.known_atw &= current_atw_ids + new_atw_ids = current_atw_ids - self.known_atw + new_atw_units = [unit for unit in current_atw_units if unit.id in new_atw_ids] + if new_atw_units: + _LOGGER.debug("Discovered new ATW units: %s", new_atw_units) + self.known_atw.update(unit.id for unit in new_atw_units) + for atw_callback in self.new_atw_callbacks: + atw_callback(new_atw_units) + + async def _async_update_data(self) -> UserContext: + """Fetch data from the MELCloud Home API.""" + try: + data = await self.client.get_context() + except MelCloudHomeAuthenticationError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"error": repr(err)}, + ) from err + except MelCloudHomeConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except MelCloudHomeTimeoutError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="timeout_connect", + translation_placeholders={"error": repr(err)}, + ) from err + else: + return data + + @callback + def _async_refresh_finished(self) -> None: + """Notify entity callbacks after coordinator data has been updated.""" + if self.data is not None: + self._notify_new_units(self.data) diff --git a/homeassistant/components/melcloud_home/entity.py b/homeassistant/components/melcloud_home/entity.py new file mode 100644 index 000000000000..e1d52df89b14 --- /dev/null +++ b/homeassistant/components/melcloud_home/entity.py @@ -0,0 +1,84 @@ +"""Base entities for MELCloud Home.""" + +from abc import abstractmethod + +from aiomelcloudhome import ATAUnit, ATWUnit + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import MelCloudHomeCoordinator + + +class MelCloudHomeEntity(CoordinatorEntity[MelCloudHomeCoordinator]): + """Base entity for MELCloud Home.""" + + _attr_has_entity_name = True + _attr_name: str | None = None + + +class MelCloudHomeUnitEntity[_UnitT: (ATAUnit, ATWUnit)](MelCloudHomeEntity): + """Base entity for a MELCloud Home unit.""" + + def __init__(self, coordinator: MelCloudHomeCoordinator, unit: _UnitT) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._unit_id = unit.id + self._attr_unique_id = unit.id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unit.id)}, + name=unit.name, + manufacturer="Mitsubishi Electric", + ) + + @abstractmethod + def _units_dict(self) -> dict[str, _UnitT]: + """Return the coordinator's units dict keyed by id.""" + + @property + def available(self) -> bool: + """Return if the entity is available.""" + return super().available and self._unit_id in self._units_dict() + + @property + def unit(self) -> _UnitT: + """Return the current unit state from coordinator data.""" + return self._units_dict()[self._unit_id] + + +class MelCloudHomeATAUnitEntity(MelCloudHomeUnitEntity[ATAUnit]): + """Base entity for a MELCloud Home Air-to-Air unit.""" + + def _units_dict(self) -> dict[str, ATAUnit]: + """Return ATA units dict from coordinator.""" + return self.coordinator.ata_units + + +class MelCloudHomeATWUnitEntity(MelCloudHomeUnitEntity[ATWUnit]): + """Base entity for a MELCloud Home Air-to-Water unit.""" + + def _units_dict(self) -> dict[str, ATWUnit]: + """Return ATW units dict from coordinator.""" + return self.coordinator.atw_units + + +class MelCloudHomeATWZoneEntity(MelCloudHomeATWUnitEntity): + """Base entity for an ATW zone entity.""" + + def __init__( + self, + coordinator: MelCloudHomeCoordinator, + unit: ATWUnit, + zone_number: int, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, unit) + self._zone_number = zone_number + self._attr_unique_id = f"{unit.id}_zone_{zone_number}" + self._attr_name = f"Zone {zone_number}" + + @property + def zone_number(self) -> int: + """Return the zone number.""" + return self._zone_number diff --git a/homeassistant/components/melcloud_home/manifest.json b/homeassistant/components/melcloud_home/manifest.json new file mode 100644 index 000000000000..ad9bbe6d07a1 --- /dev/null +++ b/homeassistant/components/melcloud_home/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "melcloud_home", + "name": "MELCloud Home", + "codeowners": ["@erwindouna"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/melcloud_home", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["aiomelcloudhome"], + "quality_scale": "bronze", + "requirements": ["aiomelcloudhome==0.1.5"] +} diff --git a/homeassistant/components/melcloud_home/quality_scale.yaml b/homeassistant/components/melcloud_home/quality_scale.yaml new file mode 100644 index 000000000000..d98b8a46cba7 --- /dev/null +++ b/homeassistant/components/melcloud_home/quality_scale.yaml @@ -0,0 +1,68 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No custom actions defined. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No custom actions defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Coordinator handles polling. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: No custom actions defined. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/melcloud_home/strings.json b/homeassistant/components/melcloud_home/strings.json new file mode 100644 index 000000000000..033fd24bd4da --- /dev/null +++ b/homeassistant/components/melcloud_home/strings.json @@ -0,0 +1,77 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "timeout_connect": "Timeout while communicating with MELCloud Home API", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "Email address for your MELCloud Home account.", + "password": "Password for your MELCloud Home account." + }, + "description": "Login to MELCloud Home with the email address and password associated with your account." + } + } + }, + "entity": { + "climate": { + "ata_unit": { + "state_attributes": { + "fan_mode": { + "state": { + "auto": "[%key:common::state::auto%]", + "speed_1": "Speed 1", + "speed_2": "Speed 2", + "speed_3": "Speed 3", + "speed_4": "Speed 4", + "speed_5": "Speed 5" + } + }, + "swing_horizontal_mode": { + "state": { + "auto": "[%key:common::state::auto%]", + "centre": "Centre", + "left": "Left", + "left_centre": "Left centre", + "right": "Right", + "right_centre": "Right centre", + "swing": "Swing" + } + }, + "swing_mode": { + "state": { + "auto": "[%key:common::state::auto%]", + "position_1": "Position 1", + "position_2": "Position 2", + "position_3": "Position 3", + "position_4": "Position 4", + "position_5": "Position 5", + "swing": "Swing" + } + } + } + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "Error communicating with MELCloud Home API: {error}" + }, + "invalid_auth": { + "message": "An error occurred while trying to authenticate: {error}" + }, + "timeout_connect": { + "message": "Timeout while communicating with MELCloud Home API: {error}" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index cc3f0f8d6ea7..dea53bb73b85 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -448,6 +448,7 @@ FLOWS = { "medcom_ble", "media_extractor", "melcloud", + "melcloud_home", "melnor", "met", "met_eireann", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3cb76d9be1a8..30bcacba51de 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4172,6 +4172,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "melcloud_home": { + "name": "MELCloud Home", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "melissa": { "name": "Melissa", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 34fb62d680f0..1042d97ed9d9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -332,6 +332,9 @@ aiolyric==2.1.1 # homeassistant.components.mealie aiomealie==1.2.4 +# homeassistant.components.melcloud_home +aiomelcloudhome==0.1.5 + # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/tests/components/melcloud_home/__init__.py b/tests/components/melcloud_home/__init__.py new file mode 100644 index 000000000000..f5f9f7781833 --- /dev/null +++ b/tests/components/melcloud_home/__init__.py @@ -0,0 +1,15 @@ +"""Tests for the MELCloud Home integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Set up the MELCloud Home integration for testing.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/melcloud_home/conftest.py b/tests/components/melcloud_home/conftest.py new file mode 100644 index 000000000000..b74a365937a5 --- /dev/null +++ b/tests/components/melcloud_home/conftest.py @@ -0,0 +1,48 @@ +"""Common fixtures for the MELCloud Home tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from aiomelcloudhome import MELCloudHome, UserContext +import pytest + +from homeassistant.components.melcloud_home.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from tests.common import MockConfigEntry, load_json_value_fixture + +MOCK_USER_INPUT = { + CONF_EMAIL: "user@example.com", + CONF_PASSWORD: "thatyouevenlookedheretoseethepassword", +} + + +@pytest.fixture +def mock_melcloud_client() -> Generator[AsyncMock]: + """Mock MELCloud Home client.""" + client = AsyncMock(MELCloudHome) + client.get_context.return_value = UserContext.model_validate( + load_json_value_fixture("context.json", DOMAIN) + ) + with ( + patch( + "homeassistant.components.melcloud_home.MELCloudHome", + return_value=client, + ), + patch( + "homeassistant.components.melcloud_home.config_flow.MELCloudHome", + return_value=client, + ), + ): + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a MELCloud Home config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="user-uuid-1", + title=MOCK_USER_INPUT[CONF_EMAIL], + data=MOCK_USER_INPUT, + ) diff --git a/tests/components/melcloud_home/fixtures/context.json b/tests/components/melcloud_home/fixtures/context.json new file mode 100644 index 000000000000..14ace8e4eb08 --- /dev/null +++ b/tests/components/melcloud_home/fixtures/context.json @@ -0,0 +1,202 @@ +{ + "id": "user-uuid-1", + "firstname": "First", + "lastname": "Last", + "email": "user@example.com", + "language": "nl", + "country": "NL", + "numberOfDevicesAllowed": 10, + "numberOfBuildingsAllowed": 2, + "numberOfGuestUsersAllowedPerUnit": 5, + "numberOfGuestDevicesAllowed": 10, + "buildings": [ + { + "id": "building-uuid-1", + "name": "My Home", + "timezone": "Europe/Amsterdam", + "airToAirUnits": [ + { + "unitSettings": null, + "schedule": [], + "scheduleEnabled": true, + "connectedInterfaceIdentifier": "iface-identifier-1", + "capabilities": { + "isMultiSplitSystem": false, + "isLegacyDevice": false, + "hasStandby": false, + "hasCoolOperationMode": true, + "hasHeatOperationMode": true, + "hasAutoOperationMode": true, + "hasDryOperationMode": true, + "hasAutomaticFanSpeed": true, + "hasAirDirection": true, + "hasSwing": true, + "hasExtendedTemperatureRange": true, + "hasEnergyConsumedMeter": false, + "numberOfFanSpeeds": 5, + "minTempCool": 16, + "maxTempCool": 31, + "minTempHeat": 10, + "maxTempHeat": 31, + "minTempAutomatic": 16, + "maxTempAutomatic": 31, + "hasDemandSideControl": false, + "hasHalfDegreeIncrements": true, + "supportsWideVane": false, + "hasOutdoorTemperatureSensor": false, + "hasVaneVertical": true, + "hasVaneHorizontal": true, + "hasStandbyMode": false + }, + "frostProtection": { + "active": false, + "enabled": true, + "min": 10, + "max": 12 + }, + "overheatProtection": { + "active": false, + "enabled": true, + "min": 35, + "max": 37 + }, + "holidayMode": null, + "connectedInterfaceType": "melCloudWiFi", + "systemId": "system-uuid-1", + "id": "ata-unit-uuid-1", + "givenDisplayName": "Living Room AC", + "displayIcon": "Room", + "settings": [ + { + "name": "RoomTemperature", + "value": "20" + }, + { + "name": "Power", + "value": "True" + }, + { + "name": "OperationMode", + "value": "Heat" + }, + { + "name": "ActualFanSpeed", + "value": "Off" + }, + { + "name": "SetFanSpeed", + "value": "Auto" + }, + { + "name": "VaneHorizontalDirection", + "value": "Centre" + }, + { + "name": "VaneVerticalDirection", + "value": "Auto" + }, + { + "name": "InStandbyMode", + "value": "False" + }, + { + "name": "SetTemperature", + "value": "21" + }, + { + "name": "IsInError", + "value": "False" + }, + { + "name": "ErrorCode", + "value": "" + } + ], + "timeZone": "Europe/Amsterdam", + "rssi": -45, + "isConnected": true, + "isInError": false, + "energyProducedOptIn": null, + "isEnergyUsageCompatible": false + } + ], + "airToWaterUnits": [ + { + "id": "atw-unit-uuid-1", + "givenDisplayName": "Heat Pump", + "settings": [ + { + "name": "Power", + "value": "True" + }, + { + "name": "OperationMode", + "value": "HeatZones" + }, + { + "name": "OperationModeZone1", + "value": "HeatRoomTemperature" + }, + { + "name": "SetTemperatureZone1", + "value": "21" + }, + { + "name": "RoomTemperatureZone1", + "value": "20" + }, + { + "name": "SetTankWaterTemperature", + "value": "50" + }, + { + "name": "TankWaterTemperature", + "value": "48" + }, + { + "name": "ForcedHotWaterMode", + "value": "False" + }, + { + "name": "HasZone2", + "value": "1" + }, + { + "name": "OperationModeZone2", + "value": "HeatRoomTemperature" + }, + { + "name": "SetTemperatureZone2", + "value": "22" + }, + { + "name": "RoomTemperatureZone2", + "value": "21" + }, + { + "name": "InStandbyMode", + "value": "False" + }, + { + "name": "IsInError", + "value": "False" + } + ], + "capabilities": { + "hasHotWater": true, + "hasZone2": true, + "hasHalfDegrees": false, + "hasCoolingMode": true, + "minSetTankTemperature": 40, + "maxSetTankTemperature": 60, + "hasStandbyMode": false, + "hasEnergyConsumedMeter": true + }, + "rssi": -52 + } + ] + } + ], + "guestBuildings": [], + "scenes": [] +} diff --git a/tests/components/melcloud_home/snapshots/test_climate.ambr b/tests/components/melcloud_home/snapshots/test_climate.ambr new file mode 100644 index 000000000000..de43feeb7a73 --- /dev/null +++ b/tests/components/melcloud_home/snapshots/test_climate.ambr @@ -0,0 +1,266 @@ +# serializer version: 1 +# name: test_climate_platform[climate.heat_pump_zone_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.heat_pump_zone_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Zone 1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Zone 1', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'atw-unit-uuid-1_zone_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_platform[climate.heat_pump_zone_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.0, + 'friendly_name': 'Heat Pump Zone 1', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'temperature': 21.0, + }), + 'context': , + 'entity_id': 'climate.heat_pump_zone_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_platform[climate.heat_pump_zone_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.heat_pump_zone_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Zone 2', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Zone 2', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'atw-unit-uuid-1_zone_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_platform[climate.heat_pump_zone_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.0, + 'friendly_name': 'Heat Pump Zone 2', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.heat_pump_zone_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_platform[climate.living_room_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'speed_1', + 'speed_2', + 'speed_3', + 'speed_4', + 'speed_5', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'swing_horizontal_modes': list([ + 'auto', + 'swing', + 'left', + 'left_centre', + 'centre', + 'right_centre', + 'right', + ]), + 'swing_modes': list([ + 'auto', + 'swing', + 'position_1', + 'position_2', + 'position_3', + 'position_4', + 'position_5', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.living_room_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ata_unit', + 'unique_id': 'ata-unit-uuid-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_platform[climate.living_room_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.0, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'auto', + 'speed_1', + 'speed_2', + 'speed_3', + 'speed_4', + 'speed_5', + ]), + 'friendly_name': 'Living Room AC', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'swing_horizontal_mode': 'centre', + 'swing_horizontal_modes': list([ + 'auto', + 'swing', + 'left', + 'left_centre', + 'centre', + 'right_centre', + 'right', + ]), + 'swing_mode': 'auto', + 'swing_modes': list([ + 'auto', + 'swing', + 'position_1', + 'position_2', + 'position_3', + 'position_4', + 'position_5', + ]), + 'temperature': 21.0, + }), + 'context': , + 'entity_id': 'climate.living_room_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/melcloud_home/test_climate.py b/tests/components/melcloud_home/test_climate.py new file mode 100644 index 000000000000..eff5e35aa9b4 --- /dev/null +++ b/tests/components/melcloud_home/test_climate.py @@ -0,0 +1,362 @@ +"""Test the MELCloud Home climate platform.""" + +from unittest.mock import AsyncMock + +from aiomelcloudhome import ( + ATAFanSpeed, + ATAOperationMode, + ATAVaneHorizontal, + ATAVaneVertical, + ATWZoneMode, +) +import pytest + +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HVAC_MODE, + ATTR_SWING_HORIZONTAL_MODE, + ATTR_SWING_MODE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, SnapshotAssertion, snapshot_platform + +ATA_ENTITY_ID = "climate.living_room_ac" +ATW_ZONE1_ENTITY_ID = "climate.heat_pump_zone_1" +ATW_ZONE2_ENTITY_ID = "climate.heat_pump_zone_2" + + +@pytest.mark.usefixtures("mock_melcloud_client") +async def test_climate_platform( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test all climate entity states and attributes from fixture data.""" + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("hvac_mode", "arguments"), + [ + pytest.param(HVACMode.OFF, {"power": False}, id="off"), + pytest.param( + HVACMode.HEAT, + {"power": True, "operation_mode": ATAOperationMode.HEAT}, + ), + pytest.param( + HVACMode.COOL, + {"power": True, "operation_mode": ATAOperationMode.COOL}, + ), + pytest.param( + HVACMode.AUTO, + {"power": True, "operation_mode": ATAOperationMode.AUTOMATIC}, + ), + pytest.param( + HVACMode.DRY, + {"power": True, "operation_mode": ATAOperationMode.DRY}, + ), + pytest.param( + HVACMode.FAN_ONLY, + {"power": True, "operation_mode": ATAOperationMode.FAN}, + ), + ], +) +@pytest.mark.usefixtures("mock_melcloud_client") +async def test_ata_set_hvac_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_melcloud_client: AsyncMock, + hvac_mode: HVACMode, + arguments: dict, +) -> None: + """Test setting HVAC mode on an ATA unit.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ATA_ENTITY_ID, ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + + mock_melcloud_client.control_ata_unit.assert_called_once_with( + "ata-unit-uuid-1", **arguments + ) + + +@pytest.mark.parametrize( + ("fan_mode", "expected_speed"), + [ + pytest.param("auto", ATAFanSpeed.AUTO), + pytest.param("speed_1", ATAFanSpeed.ONE), + pytest.param("speed_2", ATAFanSpeed.TWO), + pytest.param("speed_3", ATAFanSpeed.THREE), + pytest.param("speed_4", ATAFanSpeed.FOUR), + pytest.param("speed_5", ATAFanSpeed.FIVE), + ], +) +@pytest.mark.usefixtures("mock_melcloud_client") +async def test_ata_set_fan_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_melcloud_client: AsyncMock, + fan_mode: str, + expected_speed: ATAFanSpeed, +) -> None: + """Test setting fan speed on an ATA unit.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ATA_ENTITY_ID, ATTR_FAN_MODE: fan_mode}, + blocking=True, + ) + + mock_melcloud_client.control_ata_unit.assert_called_once_with( + "ata-unit-uuid-1", set_fan_speed=expected_speed + ) + + +@pytest.mark.parametrize( + ("swing_mode", "expected_vane"), + [ + pytest.param("auto", ATAVaneVertical.AUTO), + pytest.param("swing", ATAVaneVertical.SWING), + pytest.param("position_1", ATAVaneVertical.ONE), + pytest.param("position_2", ATAVaneVertical.TWO), + pytest.param("position_3", ATAVaneVertical.THREE), + pytest.param("position_4", ATAVaneVertical.FOUR), + pytest.param("position_5", ATAVaneVertical.FIVE), + ], +) +@pytest.mark.usefixtures("mock_melcloud_client") +async def test_ata_set_swing_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_melcloud_client: AsyncMock, + swing_mode: str, + expected_vane: ATAVaneVertical, +) -> None: + """Test setting vertical vane direction on an ATA unit.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: ATA_ENTITY_ID, ATTR_SWING_MODE: swing_mode}, + blocking=True, + ) + + mock_melcloud_client.control_ata_unit.assert_called_once_with( + "ata-unit-uuid-1", vane_vertical_direction=expected_vane + ) + + +@pytest.mark.parametrize( + ("swing_mode", "expected_vane"), + [ + pytest.param("auto", ATAVaneHorizontal.AUTO), + pytest.param("swing", ATAVaneHorizontal.SWING), + pytest.param("left", ATAVaneHorizontal.LEFT), + pytest.param("left_centre", ATAVaneHorizontal.LEFT_CENTRE), + pytest.param("centre", ATAVaneHorizontal.CENTRE), + pytest.param("right_centre", ATAVaneHorizontal.RIGHT_CENTRE), + pytest.param("right", ATAVaneHorizontal.RIGHT), + ], +) +@pytest.mark.usefixtures("mock_melcloud_client") +async def test_ata_set_swing_horizontal_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_melcloud_client: AsyncMock, + swing_mode: str, + expected_vane: ATAVaneHorizontal, +) -> None: + """Test setting horizontal vane direction on an ATA unit.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + {ATTR_ENTITY_ID: ATA_ENTITY_ID, ATTR_SWING_HORIZONTAL_MODE: swing_mode}, + blocking=True, + ) + + mock_melcloud_client.control_ata_unit.assert_called_once_with( + "ata-unit-uuid-1", vane_horizontal_direction=expected_vane + ) + + +@pytest.mark.usefixtures("mock_melcloud_client") +async def test_ata_set_temperature( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_melcloud_client: AsyncMock, +) -> None: + """Test setting target temperature on an ATA unit.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ATA_ENTITY_ID, ATTR_TEMPERATURE: 22.5}, + blocking=True, + ) + + mock_melcloud_client.control_ata_unit.assert_called_once_with( + "ata-unit-uuid-1", set_temperature=22.5 + ) + + +@pytest.mark.parametrize( + ("service", "expected_kwargs"), + [ + pytest.param(SERVICE_TURN_ON, {"power": True}), + pytest.param(SERVICE_TURN_OFF, {"power": False}), + ], +) +@pytest.mark.usefixtures("mock_melcloud_client") +async def test_ata_turn_on_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_melcloud_client: AsyncMock, + service: str, + expected_kwargs: dict, +) -> None: + """Test turning an ATA unit on and off.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: ATA_ENTITY_ID}, + blocking=True, + ) + + mock_melcloud_client.control_ata_unit.assert_called_once_with( + "ata-unit-uuid-1", **expected_kwargs + ) + + +@pytest.mark.parametrize( + ("entity_id", "hvac_mode", "expected_kwargs"), + [ + pytest.param( + ATW_ZONE1_ENTITY_ID, HVACMode.OFF, {"power": False}, id="zone1_off" + ), + pytest.param( + ATW_ZONE1_ENTITY_ID, + HVACMode.HEAT, + {"power": True, "operation_mode_zone1": ATWZoneMode.HEAT_ROOM_TEMPERATURE}, + id="zone1_heat", + ), + pytest.param( + ATW_ZONE2_ENTITY_ID, + HVACMode.HEAT, + {"power": True, "operation_mode_zone2": ATWZoneMode.HEAT_ROOM_TEMPERATURE}, + id="zone2_heat", + ), + ], +) +@pytest.mark.usefixtures("mock_melcloud_client") +async def test_atw_set_hvac_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_melcloud_client: AsyncMock, + entity_id: str, + hvac_mode: HVACMode, + expected_kwargs: dict, +) -> None: + """Test setting HVAC mode on an ATW zone.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + + mock_melcloud_client.control_atw_unit.assert_called_once_with( + "atw-unit-uuid-1", **expected_kwargs + ) + + +@pytest.mark.parametrize( + ("entity_id", "expected_kwargs"), + [ + pytest.param(ATW_ZONE1_ENTITY_ID, {"set_temperature_zone1": 23.0}, id="zone1"), + pytest.param(ATW_ZONE2_ENTITY_ID, {"set_temperature_zone2": 23.0}, id="zone2"), + ], +) +@pytest.mark.usefixtures("mock_melcloud_client") +async def test_atw_set_temperature( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_melcloud_client: AsyncMock, + entity_id: str, + expected_kwargs: dict, +) -> None: + """Test setting target temperature on an ATW zone.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 23.0}, + blocking=True, + ) + + mock_melcloud_client.control_atw_unit.assert_called_once_with( + "atw-unit-uuid-1", **expected_kwargs + ) + + +@pytest.mark.parametrize( + ("service", "expected_kwargs"), + [ + pytest.param(SERVICE_TURN_ON, {"power": True}), + pytest.param(SERVICE_TURN_OFF, {"power": False}), + ], +) +@pytest.mark.usefixtures("mock_melcloud_client") +async def test_atw_turn_on_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_melcloud_client: AsyncMock, + service: str, + expected_kwargs: dict, +) -> None: + """Test turning an ATW zone on and off.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: ATW_ZONE1_ENTITY_ID}, + blocking=True, + ) + + mock_melcloud_client.control_atw_unit.assert_called_once_with( + "atw-unit-uuid-1", **expected_kwargs + ) diff --git a/tests/components/melcloud_home/test_config_flow.py b/tests/components/melcloud_home/test_config_flow.py new file mode 100644 index 000000000000..397ca3e7bf5c --- /dev/null +++ b/tests/components/melcloud_home/test_config_flow.py @@ -0,0 +1,108 @@ +"""Test the MELCloud Home config flow.""" + +from unittest.mock import AsyncMock + +from aiomelcloudhome.exceptions import ( + MelCloudHomeAuthenticationError, + MelCloudHomeConnectionError, + MelCloudHomeTimeoutError, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.melcloud_home.const import DOMAIN +from homeassistant.const import CONF_EMAIL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import MOCK_USER_INPUT + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_melcloud_client") +async def test_user_flow(hass: HomeAssistant) -> None: + """Test the full user config flow creates an entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_USER_INPUT[CONF_EMAIL] + assert result["data"] == MOCK_USER_INPUT + assert result["result"].unique_id == "user-uuid-1" + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + (MelCloudHomeAuthenticationError("bad creds"), "invalid_auth"), + (MelCloudHomeConnectionError("offline"), "cannot_connect"), + (MelCloudHomeTimeoutError("timed out"), "timeout_connect"), + (Exception("unexpected"), "unknown"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + mock_melcloud_client: AsyncMock, + exception: Exception, + reason: str, +) -> None: + """Test we handle all user step exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_melcloud_client.get_context.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": reason} + + mock_melcloud_client.get_context.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_USER_INPUT[CONF_EMAIL] + assert result["data"] == MOCK_USER_INPUT + + +@pytest.mark.usefixtures("mock_melcloud_client") +async def test_duplicate_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we handle duplicate entries.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_INPUT, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/melcloud_home/test_init.py b/tests/components/melcloud_home/test_init.py new file mode 100644 index 000000000000..6da231326349 --- /dev/null +++ b/tests/components/melcloud_home/test_init.py @@ -0,0 +1,138 @@ +"""Test the MELCloud Home integration init behavior.""" + +from unittest.mock import AsyncMock + +from aiomelcloudhome import UserContext +from aiomelcloudhome.exceptions import ( + MelCloudHomeAuthenticationError, + MelCloudHomeConnectionError, + MelCloudHomeTimeoutError, +) +import pytest + +from homeassistant.components.melcloud_home.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_load_json_object_fixture + + +@pytest.mark.usefixtures("mock_melcloud_client") +async def test_entry_setup_unload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test integration setup and unload.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + "exception", + [ + MelCloudHomeAuthenticationError("bad creds"), + MelCloudHomeConnectionError("cannot connect"), + MelCloudHomeTimeoutError("timeout"), + ], +) +async def test_entry_setup_retry_on_update_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_melcloud_client: AsyncMock, + exception: Exception, +) -> None: + """Test setup retries when initial coordinator refresh fails.""" + mock_melcloud_client.get_context.side_effect = exception + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_new_ata_unit_callback( + hass: HomeAssistant, + mock_melcloud_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that new ATA units discovered on coordinator refresh create climate entities.""" + fixture = await async_load_json_object_fixture(hass, "context.json", DOMAIN) + mock_melcloud_client.get_context.return_value = UserContext.model_validate( + { + **fixture, + "buildings": [ + {**building, "airToAirUnits": []} for building in fixture["buildings"] + ], + } + ) + await setup_integration(hass, mock_config_entry) + ata_entities = [ + entity + for entity in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + if "living_room" in entity.entity_id + ] + assert not ata_entities + + mock_melcloud_client.get_context.return_value = UserContext.model_validate(fixture) + await mock_config_entry.runtime_data.async_refresh() + await hass.async_block_till_done() + + ata_entities = [ + entity + for entity in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + if "living_room" in entity.entity_id + ] + assert ata_entities + + +async def test_new_atw_unit_callback( + hass: HomeAssistant, + mock_melcloud_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that new ATW units discovered on coordinator refresh create climate entities.""" + fixture = await async_load_json_object_fixture(hass, "context.json", DOMAIN) + mock_melcloud_client.get_context.return_value = UserContext.model_validate( + { + **fixture, + "buildings": [ + {**building, "airToWaterUnits": []} for building in fixture["buildings"] + ], + } + ) + await setup_integration(hass, mock_config_entry) + atw_entities = [ + entity + for entity in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + if "heat_pump" in entity.entity_id + ] + assert not atw_entities + + mock_melcloud_client.get_context.return_value = UserContext.model_validate(fixture) + await mock_config_entry.runtime_data.async_refresh() + await hass.async_block_till_done() + + atw_entities = [ + entity + for entity in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + if "heat_pump" in entity.entity_id + ] + assert atw_entities From 3380a8ff29a27b377927b14f5b15678cf55f9af9 Mon Sep 17 00:00:00 2001 From: Imou-OpenPlatform Date: Thu, 11 Jun 2026 14:18:42 +0800 Subject: [PATCH 095/404] Adds the camera platform for the Imou integration (#173064) --- homeassistant/components/imou/camera.py | 106 ++++++ homeassistant/components/imou/const.py | 4 +- homeassistant/components/imou/strings.json | 8 + tests/components/imou/conftest.py | 15 + tests/components/imou/const.py | 10 + .../imou/snapshots/test_camera.ambr | 241 ++++++++++++ tests/components/imou/test_camera.py | 345 ++++++++++++++++++ tests/components/imou/test_init.py | 16 +- 8 files changed, 740 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/imou/camera.py create mode 100644 tests/components/imou/snapshots/test_camera.ambr create mode 100644 tests/components/imou/test_camera.py diff --git a/homeassistant/components/imou/camera.py b/homeassistant/components/imou/camera.py new file mode 100644 index 000000000000..442703a56e21 --- /dev/null +++ b/homeassistant/components/imou/camera.py @@ -0,0 +1,106 @@ +"""Support for Imou camera entities.""" + +from pyimouapi.const import PARAM_HD, PARAM_MOTION_DETECT, PARAM_STATE +from pyimouapi.exceptions import ImouException +from pyimouapi.ha_device import ImouHaDevice + +from homeassistant.components.camera import Camera, CameraEntityFeature +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import PARAM_HEADER_DETECT, imou_device_identifier +from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator +from .entity import ImouEntity + +PARALLEL_UPDATES = 0 + +CAMERA_STREAM_RESOLUTION_SD = "SD" + +# Defaults for pyimouapi ImouHaDeviceManager APIs (async_get_device_stream / async_get_device_image). +PYIMOUAPI_LIVE_PROTOCOL = "https" +PYIMOUAPI_SNAPSHOT_WAIT_SECONDS = 3 + +CAMERA_TYPES = ( + ("camera_sd", CAMERA_STREAM_RESOLUTION_SD), + ("camera_hd", PARAM_HD), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ImouConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Imou camera entities.""" + coordinator = entry.runtime_data + + def _add_cameras(new_devices: list[ImouHaDevice]) -> None: + device_keys = {imou_device_identifier(device) for device in new_devices} + async_add_entities( + ImouCamera(coordinator, entity_type, device, resolution) + for device in coordinator.devices + if device.channel_id is not None + if imou_device_identifier(device) in device_keys + for entity_type, resolution in CAMERA_TYPES + ) + + coordinator.new_device_callbacks.append(_add_cameras) + + @callback + def _remove_new_device_callback() -> None: + if _add_cameras in coordinator.new_device_callbacks: + coordinator.new_device_callbacks.remove(_add_cameras) + + entry.async_on_unload(_remove_new_device_callback) + _add_cameras(coordinator.devices) + + +class ImouCamera(ImouEntity, Camera): + """Representation of an Imou camera stream.""" + + _attr_supported_features = CameraEntityFeature.STREAM + + def __init__( + self, + coordinator: ImouDataUpdateCoordinator, + entity_type: str, + device: ImouHaDevice, + resolution: str, + ) -> None: + """Initialize the camera entity.""" + self._resolution = resolution + Camera.__init__(self) + super().__init__(coordinator, entity_type, device) + + async def stream_source(self) -> str | None: + """Return the live stream URL from the Imou cloud.""" + try: + return await self.coordinator.device_manager.async_get_device_stream( + self.device, + self._resolution, + PYIMOUAPI_LIVE_PROTOCOL, + ) + except ImouException as err: + raise HomeAssistantError(str(err)) from err + + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return bytes of camera image.""" + try: + return await self.coordinator.device_manager.async_get_device_image( + self.device, + PYIMOUAPI_SNAPSHOT_WAIT_SECONDS, + ) + except ImouException as err: + raise HomeAssistantError(str(err)) from err + + @property + def motion_detection_enabled(self) -> bool: + """Return True when human and/or motion detection switch is on.""" + header = self.device.switches.get(PARAM_HEADER_DETECT) + motion = self.device.switches.get(PARAM_MOTION_DETECT) + header_on = bool(header[PARAM_STATE]) if header else False + motion_on = bool(motion[PARAM_STATE]) if motion else False + return header_on or motion_on diff --git a/homeassistant/components/imou/const.py b/homeassistant/components/imou/const.py index d315aa6b1c2f..e0d86b0acc0c 100644 --- a/homeassistant/components/imou/const.py +++ b/homeassistant/components/imou/const.py @@ -28,7 +28,7 @@ CONF_APP_SECRET = "app_secret" PARAM_STATUS = "status" PARAM_STATE = "state" - +PARAM_HEADER_DETECT = "header_detect" # How long each PTZ button press moves the camera, in milliseconds (Imou cloud API). PTZ_MOVE_DURATION_MS = 500 @@ -36,4 +36,4 @@ PTZ_MOVE_DURATION_MS = 500 # Upper bound for a full coordinator refresh (device list + status for all devices). UPDATE_TIMEOUT = 300 -PLATFORMS = [Platform.BUTTON] +PLATFORMS = [Platform.BUTTON, Platform.CAMERA] diff --git a/homeassistant/components/imou/strings.json b/homeassistant/components/imou/strings.json index ea7bed1bc65f..1f6dc7080f9c 100644 --- a/homeassistant/components/imou/strings.json +++ b/homeassistant/components/imou/strings.json @@ -41,6 +41,14 @@ "ptz_up": { "name": "PTZ up" } + }, + "camera": { + "camera_hd": { + "name": "Live view HD" + }, + "camera_sd": { + "name": "Live view SD" + } } }, "selector": { diff --git a/tests/components/imou/conftest.py b/tests/components/imou/conftest.py index b3ec48685c1d..fc46a3945745 100644 --- a/tests/components/imou/conftest.py +++ b/tests/components/imou/conftest.py @@ -87,3 +87,18 @@ async def init_integration( assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() return mock_imou_ha_device_manager + + +@pytest.fixture +async def init_integration_stable_camera( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_imou_openapi_client: AsyncMock, + mock_imou_ha_device_manager: MagicMock, +) -> MagicMock: + """Set up Imou with stable camera access tokens for snapshot tests.""" + mock_config_entry.add_to_hass(hass) + with patch("random.SystemRandom.getrandbits", return_value=123123123123): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_imou_ha_device_manager diff --git a/tests/components/imou/const.py b/tests/components/imou/const.py index dc222129e003..8bd4d80b9dba 100644 --- a/tests/components/imou/const.py +++ b/tests/components/imou/const.py @@ -40,6 +40,8 @@ def create_online_device( *, channel_id: str | None = None, button_keys: tuple[str, ...] = (), + switches: dict[str, dict] | None = None, + sensors: dict[str, dict] | None = None, ) -> ImouHaDevice: """Build an online ImouHaDevice for tests.""" return create_device( @@ -48,6 +50,8 @@ def create_online_device( channel_id=channel_id, button_keys=button_keys, status=DeviceStatus.ONLINE, + switches=switches, + sensors=sensors, ) @@ -75,6 +79,8 @@ def create_device( channel_id: str | None = None, button_keys: tuple[str, ...] = (), status: DeviceStatus = DeviceStatus.ONLINE, + switches: dict[str, dict] | None = None, + sensors: dict[str, dict] | None = None, ) -> ImouHaDevice: """Build an ImouHaDevice for tests.""" device = ImouHaDevice(device_id, name, "Imou", "m1", "1.0") @@ -83,6 +89,10 @@ def create_device( for key in button_keys: device._buttons[key] = {} device._sensors[PARAM_STATUS] = {PARAM_STATE: status.value} + if switches: + device._switches.update(switches) + if sensors: + device._sensors.update(sensors) return device diff --git a/tests/components/imou/snapshots/test_camera.ambr b/tests/components/imou/snapshots/test_camera.ambr new file mode 100644 index 000000000000..0722557be642 --- /dev/null +++ b/tests/components/imou/snapshots/test_camera.ambr @@ -0,0 +1,241 @@ +# serializer version: 1 +# name: test_camera_entities_snapshot[imou_mock_devices0][camera.device_1_live_view_hd-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': 'camera', + 'entity_category': None, + 'entity_id': 'camera.device_1_live_view_hd', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Live view HD', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Live view HD', + 'platform': 'imou', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'camera_hd', + 'unique_id': 'd1_1$camera_hd', + 'unit_of_measurement': None, + }) +# --- +# name: test_camera_entities_snapshot[imou_mock_devices0][camera.device_1_live_view_hd-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'entity_picture': '/api/camera_proxy/camera.device_1_live_view_hd?token=1caab5c3b3', + 'friendly_name': 'Device 1 Live view HD', + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.device_1_live_view_hd', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_camera_entities_snapshot[imou_mock_devices0][camera.device_1_live_view_sd-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': 'camera', + 'entity_category': None, + 'entity_id': 'camera.device_1_live_view_sd', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Live view SD', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Live view SD', + 'platform': 'imou', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'camera_sd', + 'unique_id': 'd1_1$camera_sd', + 'unit_of_measurement': None, + }) +# --- +# name: test_camera_entities_snapshot[imou_mock_devices0][camera.device_1_live_view_sd-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'entity_picture': '/api/camera_proxy/camera.device_1_live_view_sd?token=1caab5c3b3', + 'friendly_name': 'Device 1 Live view SD', + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.device_1_live_view_sd', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_camera_state[both_detect_on-camera_hd] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'entity_picture': '/api/camera_proxy/camera.device_1_live_view_hd?token=1caab5c3b3', + 'friendly_name': 'Device 1 Live view HD', + 'motion_detection': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.device_1_live_view_hd', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_camera_state[both_detect_on-camera_sd] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'entity_picture': '/api/camera_proxy/camera.device_1_live_view_sd?token=1caab5c3b3', + 'friendly_name': 'Device 1 Live view SD', + 'motion_detection': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.device_1_live_view_sd', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_camera_state[header_detect_on-camera_hd] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'entity_picture': '/api/camera_proxy/camera.device_1_live_view_hd?token=1caab5c3b3', + 'friendly_name': 'Device 1 Live view HD', + 'motion_detection': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.device_1_live_view_hd', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_camera_state[header_detect_on-camera_sd] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'entity_picture': '/api/camera_proxy/camera.device_1_live_view_sd?token=1caab5c3b3', + 'friendly_name': 'Device 1 Live view SD', + 'motion_detection': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.device_1_live_view_sd', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_camera_state[motion_detect_on-camera_hd] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'entity_picture': '/api/camera_proxy/camera.device_1_live_view_hd?token=1caab5c3b3', + 'friendly_name': 'Device 1 Live view HD', + 'motion_detection': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.device_1_live_view_hd', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_camera_state[motion_detect_on-camera_sd] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'entity_picture': '/api/camera_proxy/camera.device_1_live_view_sd?token=1caab5c3b3', + 'friendly_name': 'Device 1 Live view SD', + 'motion_detection': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.device_1_live_view_sd', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_camera_state[no_detect_switches-camera_hd] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'entity_picture': '/api/camera_proxy/camera.device_1_live_view_hd?token=1caab5c3b3', + 'friendly_name': 'Device 1 Live view HD', + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.device_1_live_view_hd', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_camera_state[no_detect_switches-camera_sd] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'entity_picture': '/api/camera_proxy/camera.device_1_live_view_sd?token=1caab5c3b3', + 'friendly_name': 'Device 1 Live view SD', + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.device_1_live_view_sd', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/imou/test_camera.py b/tests/components/imou/test_camera.py new file mode 100644 index 000000000000..5f6bf2fc8c2e --- /dev/null +++ b/tests/components/imou/test_camera.py @@ -0,0 +1,345 @@ +"""Tests for Imou camera platform.""" + +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from pyimouapi.const import PARAM_HD, PARAM_MOTION_DETECT, PARAM_STATE +from pyimouapi.exceptions import ImouException +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.camera import async_get_image, async_get_stream_source +from homeassistant.components.imou.camera import ( + CAMERA_STREAM_RESOLUTION_SD, + PYIMOUAPI_LIVE_PROTOCOL, + PYIMOUAPI_SNAPSHOT_WAIT_SECONDS, +) +from homeassistant.components.imou.const import PARAM_HEADER_DETECT +from homeassistant.components.imou.coordinator import SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .const import create_online_device + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +TEST_STREAM_URL = "https://example.com/live.m3u8" +TEST_IMAGE_BYTES = b"fake-image-bytes" + + +def _camera_entity_id( + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, + *, + device_key: str = "d1_1", + camera_key: str = "camera_sd", +) -> str: + """Return the entity id for a channel camera.""" + entry = next( + registry_entry + for registry_entry in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + if registry_entry.unique_id == f"{device_key}${camera_key}" + ) + return entry.entity_id + + +@pytest.mark.parametrize( + "imou_mock_devices", + [ + [ + create_online_device( + "d1", + "Device 1", + channel_id="1", + button_keys=(), + ) + ] + ], + indirect=True, +) +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "init_integration_stable_camera" +) +async def test_camera_entities_snapshot( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Snapshot camera entities and states for a channel device.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "imou_mock_devices", + [ + [ + create_online_device( + "d1", + "Device 1", + button_keys=(), + ) + ] + ], + indirect=True, +) +@pytest.mark.usefixtures("init_integration") +async def test_no_camera_without_channel( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Devices without a channel do not get a camera entity.""" + registry = er.async_get(hass) + entries = er.async_entries_for_config_entry(registry, mock_config_entry.entry_id) + assert not any(entry.domain == "camera" for entry in entries) + + +@pytest.mark.parametrize( + ("camera_key", "expected_resolution"), + [ + ("camera_sd", CAMERA_STREAM_RESOLUTION_SD), + ("camera_hd", PARAM_HD), + ], +) +@pytest.mark.parametrize( + "imou_mock_devices", + [ + [ + create_online_device( + "d1", + "Device 1", + channel_id="1", + button_keys=(), + ) + ] + ], + indirect=True, +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_camera_stream_source( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MagicMock, + mock_config_entry: MockConfigEntry, + camera_key: str, + expected_resolution: str, +) -> None: + """Fetching stream source calls the vendor library with the entity resolution.""" + init_integration.async_get_device_stream.return_value = TEST_STREAM_URL + + entity_id = _camera_entity_id( + entity_registry, mock_config_entry, camera_key=camera_key + ) + stream_source = await async_get_stream_source(hass, entity_id) + + assert stream_source == TEST_STREAM_URL + init_integration.async_get_device_stream.assert_awaited_once() + call = init_integration.async_get_device_stream.await_args + assert call is not None + assert call.args[1] == expected_resolution + assert call.args[2] == PYIMOUAPI_LIVE_PROTOCOL + + +@pytest.mark.parametrize( + "imou_mock_devices", + [ + [ + create_online_device( + "d1", + "Device 1", + channel_id="1", + button_keys=(), + ) + ] + ], + indirect=True, +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_camera_image( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Still image fetch calls the vendor library with the configured wait time.""" + init_integration.async_get_device_image.return_value = TEST_IMAGE_BYTES + + entity_id = _camera_entity_id(entity_registry, mock_config_entry) + image = await async_get_image(hass, entity_id) + + assert image.content == TEST_IMAGE_BYTES + init_integration.async_get_device_image.assert_awaited_once() + call = init_integration.async_get_device_image.await_args + assert call is not None + assert call.args[1] == PYIMOUAPI_SNAPSHOT_WAIT_SECONDS + + +@pytest.mark.parametrize( + "camera_key", + ["camera_sd", "camera_hd"], +) +@pytest.mark.parametrize( + "imou_mock_devices", + [ + pytest.param( + [ + create_online_device( + "d1", + "Device 1", + channel_id="1", + button_keys=(), + ) + ], + id="no_detect_switches", + ), + pytest.param( + [ + create_online_device( + "d1", + "Device 1", + channel_id="1", + button_keys=(), + switches={ + PARAM_HEADER_DETECT: {PARAM_STATE: True}, + PARAM_MOTION_DETECT: {PARAM_STATE: False}, + }, + ) + ], + id="header_detect_on", + ), + pytest.param( + [ + create_online_device( + "d1", + "Device 1", + channel_id="1", + button_keys=(), + switches={ + PARAM_HEADER_DETECT: {PARAM_STATE: False}, + PARAM_MOTION_DETECT: {PARAM_STATE: True}, + }, + ) + ], + id="motion_detect_on", + ), + pytest.param( + [ + create_online_device( + "d1", + "Device 1", + channel_id="1", + button_keys=(), + switches={ + PARAM_HEADER_DETECT: {PARAM_STATE: True}, + PARAM_MOTION_DETECT: {PARAM_STATE: True}, + }, + ) + ], + id="both_detect_on", + ), + ], + indirect=True, +) +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "init_integration_stable_camera" +) +async def test_camera_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + camera_key: str, +) -> None: + """Snapshot full camera state for motion detection switch combinations.""" + entity_id = _camera_entity_id( + entity_registry, mock_config_entry, camera_key=camera_key + ) + state = hass.states.get(entity_id) + assert state is not None + assert state == snapshot + + +@pytest.mark.parametrize( + "imou_mock_devices", + [ + [ + create_online_device( + "d1", + "Device 1", + channel_id="1", + button_keys=(), + ) + ] + ], + indirect=True, +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_camera_stream_source_propagates_api_error( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Imou API errors from stream fetch surface to the caller.""" + init_integration.async_get_device_stream.side_effect = ImouException( + "stream failure" + ) + + entity_id = _camera_entity_id(entity_registry, mock_config_entry) + with pytest.raises(HomeAssistantError, match="stream failure"): + await async_get_stream_source(hass, entity_id) + + +@pytest.mark.parametrize( + "imou_mock_devices", + [ + [ + create_online_device( + "d1", + "Device 1", + channel_id="1", + button_keys=(), + ) + ] + ], + indirect=True, +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_camera_entities_removed_when_device_leaves_account( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_imou_ha_device_manager: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Camera entities are removed when the device is no longer on the account.""" + camera_entries = [ + entry + for entry in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + if entry.domain == "camera" + ] + assert {entry.unique_id for entry in camera_entries} == { + "d1_1$camera_sd", + "d1_1$camera_hd", + } + for camera_entry in camera_entries: + assert hass.states.get(camera_entry.entity_id).state != STATE_UNAVAILABLE + + mock_imou_ha_device_manager.async_get_devices.return_value = [] + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + == [] + ) + for camera_entry in camera_entries: + assert hass.states.get(camera_entry.entity_id) is None diff --git a/tests/components/imou/test_init.py b/tests/components/imou/test_init.py index 15697913ab03..1d0b139fdeea 100644 --- a/tests/components/imou/test_init.py +++ b/tests/components/imou/test_init.py @@ -19,6 +19,12 @@ from .const import DEFAULT_MOCK_DEVICES, create_offline_device, create_online_de from tests.common import MockConfigEntry, async_fire_time_changed +EXPECTED_TRANSLATION_KEYS = { + "mute": PARAM_MUTE, + "camera_sd": "camera_sd", + "camera_hd": "camera_hd", +} + @pytest.mark.usefixtures("mock_imou_openapi_client", "mock_imou_ha_device_manager") async def test_setup_and_unload_entry( @@ -104,15 +110,19 @@ async def test_multiple_channels_create_separate_devices( entries = er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) - assert len(entries) == 2 + assert len(entries) == 6 assert {entry.unique_id for entry in entries} == { "dev-1_ch9$mute", "dev-1_ch10$mute", + "dev-1_ch9$camera_sd", + "dev-1_ch9$camera_hd", + "dev-1_ch10$camera_sd", + "dev-1_ch10$camera_hd", } for entry in entries: - assert entry.translation_key == PARAM_MUTE - device_key = entry.unique_id.split("$", 1)[0] + device_key, entity_type = entry.unique_id.split("$", 1) assert entry.device_id == device_ids_by_key[device_key] + assert entry.translation_key == EXPECTED_TRANSLATION_KEYS[entity_type] state = hass.states.get(entry.entity_id) assert state is not None assert state.state != STATE_UNAVAILABLE From 0b45db67e003ad20663f8e698e12e23ece6e3163 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 11 Jun 2026 08:29:16 +0200 Subject: [PATCH 096/404] Bump reolink_aio to 0.21.0 (#173477) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 10c48451acfa..eec71a5a8a90 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -20,5 +20,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.20.1"] + "requirements": ["reolink-aio==0.21.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1042d97ed9d9..15706869f133 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2899,7 +2899,7 @@ renault-api==0.5.12 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.20.1 +reolink-aio==0.21.0 # homeassistant.components.radio_frequency rf-protocols==4.1.0 From e04600eaeca0c934f93b99b14c7c46d83288f2aa Mon Sep 17 00:00:00 2001 From: Hai-Nam Nguyen Date: Thu, 11 Jun 2026 08:30:56 +0200 Subject: [PATCH 097/404] Bump hyponcloud to 1.0.1 (#173456) --- homeassistant/components/hypontech/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hypontech/manifest.json b/homeassistant/components/hypontech/manifest.json index 0ba59fb8be9d..aca8125aaa64 100644 --- a/homeassistant/components/hypontech/manifest.json +++ b/homeassistant/components/hypontech/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["hyponcloud==1.0.0"] + "requirements": ["hyponcloud==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 15706869f133..847670a2a09b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1305,7 +1305,7 @@ huum==0.8.2 hyperion-py==0.7.6 # homeassistant.components.hypontech -hyponcloud==1.0.0 +hyponcloud==1.0.1 # homeassistant.components.iammeter iammeter==0.2.1 From 5a27b2900334bac90b982b5ac332d1c979d2657a Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 11 Jun 2026 08:31:49 +0200 Subject: [PATCH 098/404] Bump aioamazondevices to 14.0.3 (#173478) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 69b63993fc1e..a9fc6bb090fb 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==14.0.0"] + "requirements": ["aioamazondevices==14.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 847670a2a09b..836454148df1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==14.0.0 +aioamazondevices==14.0.3 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From da035f1ca3cc1990d8198830f8f1ac49f613e4cd Mon Sep 17 00:00:00 2001 From: bkobus-bbx Date: Thu, 11 Jun 2026 09:49:25 +0200 Subject: [PATCH 099/404] Bump blebox_uniapi to v2.5.5 (#173365) --- homeassistant/components/blebox/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/blebox/manifest.json b/homeassistant/components/blebox/manifest.json index a9e79ee7d2a9..2d1e1e52543c 100644 --- a/homeassistant/components/blebox/manifest.json +++ b/homeassistant/components/blebox/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["blebox_uniapi"], - "requirements": ["blebox-uniapi==2.5.4"], + "requirements": ["blebox-uniapi==2.5.5"], "zeroconf": ["_bbxsrv._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 836454148df1..af5ba5c5505f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -666,7 +666,7 @@ bleak-retry-connector==4.6.1 bleak==3.0.2 # homeassistant.components.blebox -blebox-uniapi==2.5.4 +blebox-uniapi==2.5.5 # homeassistant.components.blink blinkpy==0.25.2 From d8ce17aaa3490500ea9bff4cb671b5e50c38eff9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 11 Jun 2026 10:11:52 +0200 Subject: [PATCH 100/404] Allow MQTT entities to be hidden by default (#168832) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/mqtt/abbreviations.py | 1 + homeassistant/components/mqtt/const.py | 1 + homeassistant/components/mqtt/entity.py | 42 +++- homeassistant/components/mqtt/schemas.py | 2 + tests/components/mqtt/test_mixins.py | 179 +++++++++++++++++- 5 files changed, 216 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 986aa46515c1..b0abaebdce34 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -273,6 +273,7 @@ ABBREVIATIONS = { "l_ver_t": "latest_version_topic", "l_ver_tpl": "latest_version_template", "pl_inst": "payload_install", + "vis": "visible_by_default", } DEVICE_ABBREVIATIONS = { diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index a045a0e96042..2e6d00371118 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -246,6 +246,7 @@ CONF_TILT_STATE_OPTIMISTIC = "tilt_optimistic" CONF_TRANSITION = "transition" CONF_URL_TEMPLATE = "url_template" CONF_URL_TOPIC = "url_topic" +CONF_VISIBLE_BY_DEFAULT = "visible_by_default" CONF_XY_COMMAND_TEMPLATE = "xy_command_template" CONF_XY_COMMAND_TOPIC = "xy_command_topic" CONF_XY_STATE_TOPIC = "xy_state_topic" diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 36f63b025345..0c297fcec82e 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -95,6 +95,7 @@ from .const import ( CONF_SW_VERSION, CONF_TOPIC, CONF_VIA_DEVICE, + CONF_VISIBLE_BY_DEFAULT, DOMAIN, MQTT_CONNECTION_STATE, ) @@ -1428,20 +1429,44 @@ class MqttEntity( # Plan to update the entity_id based on `default_entity_id` # if a deleted entity was found self._update_registry_entity_id = self.entity_id - if ( - self._config[CONF_ENABLED_BY_DEFAULT] - and deleted_entry - and deleted_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + reenable_condition := ( + deleted_entry + and self._config[CONF_ENABLED_BY_DEFAULT] + and deleted_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + ) + ) or ( + deleted_entry + and self._config[CONF_VISIBLE_BY_DEFAULT] + and deleted_entry.hidden_by is not None ): - # Enable previous deleted entity and enable it, - # if it was not disabled by the user + # Enable previous deleted entity, + # if it was not disabled by the user. + # Only reset hidden by flag if it was not hidden by the user. + if ( + deleted_entry.hidden_by is er.RegistryEntryHider.USER + and self._config[CONF_VISIBLE_BY_DEFAULT] + ): + _LOGGER.info( + "Restored entity %s was configured as visible by default, " + "but was hidden by the user before, and will remain hidden", + self.entity_id, + ) + if deleted_entry.hidden_by is er.RegistryEntryHider.USER: + hidden_by: er.RegistryEntryHider | None = er.RegistryEntryHider.USER + else: + hidden_by = ( + None + if self._config[CONF_VISIBLE_BY_DEFAULT] + else er.RegistryEntryHider.INTEGRATION + ) recreated_entry = entity_registry.async_get_or_create( entity_platform, DOMAIN, self.unique_id ) entity_registry.async_update_entity( recreated_entry.entity_id, - disabled_by=None, + disabled_by=None if reenable_condition else UNDEFINED, + hidden_by=hidden_by, ) if discovery_data is None: @@ -1590,6 +1615,9 @@ class MqttEntity( self._attr_entity_registry_enabled_default = bool( config.get(CONF_ENABLED_BY_DEFAULT, True) ) + self._attr_entity_registry_visible_default = bool( + config.get(CONF_VISIBLE_BY_DEFAULT, True) + ) self._attr_icon = config.get(CONF_ICON) self._attr_entity_picture = config.get(CONF_ENTITY_PICTURE) # Set the entity name if needed diff --git a/homeassistant/components/mqtt/schemas.py b/homeassistant/components/mqtt/schemas.py index 9cf204ede6e0..b1284f6f1c5b 100644 --- a/homeassistant/components/mqtt/schemas.py +++ b/homeassistant/components/mqtt/schemas.py @@ -52,6 +52,7 @@ from .const import ( CONF_SW_VERSION, CONF_TOPIC, CONF_VIA_DEVICE, + CONF_VISIBLE_BY_DEFAULT, DEFAULT_PAYLOAD_AVAILABLE, DEFAULT_PAYLOAD_NOT_AVAILABLE, ENTITY_PLATFORMS, @@ -184,6 +185,7 @@ MQTT_ENTITY_COMMON_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend( vol.Optional(CONF_DEFAULT_ENTITY_ID): cv.string, vol.Optional(CONF_MESSAGE_EXPIRY_INTERVAL): valid_message_expiry_interval, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_VISIBLE_BY_DEFAULT, default=True): cv.boolean, } ) diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index f23481731d6a..bc8a0040328e 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -527,13 +527,13 @@ async def test_registry_not_enabled_by_default( assert entry.disabled -async def test_registry_enable_not_enabled_by_default_entity( +async def test_registry_enable_not_enabled_or_visible_by_default_entity( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, ) -> None: - """Test enabling an entity that was not enabled by default.""" + """Test enabling an entity that was not enabled or not visible by default.""" await mqtt_mock_entry() discovery_topic = "homeassistant/sensor/bla/config" @@ -542,6 +542,7 @@ async def test_registry_enable_not_enabled_by_default_entity( "name": None, "state_topic": "state-topic", "enabled_by_default": False, + "visible_by_default": True, "unique_id": "very_unique", "default_entity_id": "sensor.test", "device": {"identifiers": "very_unique_device", "name": "test"}, @@ -552,6 +553,7 @@ async def test_registry_enable_not_enabled_by_default_entity( "name": None, "state_topic": "state-topic", "enabled_by_default": True, + "visible_by_default": False, "unique_id": "very_unique", "default_entity_id": "sensor.test", "device": {"identifiers": "very_unique_device", "name": "test"}, @@ -562,6 +564,7 @@ async def test_registry_enable_not_enabled_by_default_entity( "name": None, "state_topic": "state-topic", "enabled_by_default": True, + "visible_by_default": True, "unique_id": "very_unique", "default_entity_id": "sensor.test_new", "device": {"identifiers": "very_unique_device", "name": "test"}, @@ -575,6 +578,7 @@ async def test_registry_enable_not_enabled_by_default_entity( entry = entity_registry.async_get("sensor.test") assert entry is not None assert entry.disabled + assert entry.hidden is False assert (device_id := entry.device_id) assert device_registry.async_get(device_id) is not None @@ -588,6 +592,7 @@ async def test_registry_enable_not_enabled_by_default_entity( assert device_registry.async_get(device_id) is None # Rediscover the previous deleted entity and allow it to be enabled + # but not visible by default async_fire_mqtt_message(hass, discovery_topic, config_enabled) await hass.async_block_till_done() state = hass.states.get("sensor.test") @@ -595,10 +600,12 @@ async def test_registry_enable_not_enabled_by_default_entity( entry = entity_registry.async_get("sensor.test") assert entry is not None assert not entry.disabled + assert entry.hidden is True assert device_registry.async_get(device_id) is not None # Update entity to not be enabled by default # The entity should stay available as it was enabled before + # Also it should remain hidden async_fire_mqtt_message(hass, discovery_topic, config_disabled) await hass.async_block_till_done() state = hass.states.get("sensor.test") @@ -606,6 +613,7 @@ async def test_registry_enable_not_enabled_by_default_entity( entry = entity_registry.async_get("sensor.test") assert entry is not None assert not entry.disabled + assert entry.hidden is True assert device_registry.async_get(device_id) is not None # Delete the entity again @@ -617,12 +625,14 @@ async def test_registry_enable_not_enabled_by_default_entity( assert device_registry.async_get(device_id) is None # Repeat the re-discovery, with a new entity name + # The entity should be enabled and visible by default now async_fire_mqtt_message(hass, discovery_topic, config_enabled_new_entity_name) await hass.async_block_till_done() state = hass.states.get("sensor.test_new") assert state is not None entry = entity_registry.async_get("sensor.test_new") assert entry is not None + assert entry.hidden is False assert not entry.disabled assert device_registry.async_get(device_id) is not None @@ -645,9 +655,174 @@ async def test_registry_enable_not_enabled_by_default_entity( entry = entity_registry.async_get("sensor.test_new") assert entry is not None assert entry.disabled + assert entry.hidden is False assert device_registry.async_get(device_id) is not None +async def test_visible_by_default_with_user_override( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test visible_by_default is respected but user hidden_by override is preserved.""" + await mqtt_mock_entry() + + discovery_topic = "homeassistant/sensor/bla/config" + config_visible = json.json_dumps( + { + "name": None, + "state_topic": "state-topic", + "visible_by_default": True, + "unique_id": "very_unique", + "default_entity_id": "sensor.test", + "device": {"identifiers": "very_unique_device", "name": "test"}, + } + ) + config_hidden = json.json_dumps( + { + "name": None, + "state_topic": "state-topic", + "visible_by_default": False, + "unique_id": "very_unique", + "default_entity_id": "sensor.test", + "device": {"identifiers": "very_unique_device", "name": "test"}, + } + ) + + async_fire_mqtt_message(hass, discovery_topic, config_hidden) + await hass.async_block_till_done() + state = hass.states.get("sensor.test") + assert state is not None + entry = entity_registry.async_get("sensor.test") + assert entry is not None + assert not entry.disabled + assert entry.hidden + assert entry.hidden_by is er.RegistryEntryHider.INTEGRATION + assert (device_id := entry.device_id) + assert device_registry.async_get(device_id) is not None + + # Remove the entity and device + # At this stage no entry existed during the initialization + async_fire_mqtt_message(hass, discovery_topic, "") + await hass.async_block_till_done(wait_background_tasks=True) + entry = entity_registry.async_get("sensor.test") + assert entry is None + # Assert device is cleaned up + assert device_registry.async_get(device_id) is None + + # Rediscover the previous deleted entity, now visible by default + async_fire_mqtt_message(hass, discovery_topic, config_visible) + await hass.async_block_till_done() + state = hass.states.get("sensor.test") + assert state is not None + entry = entity_registry.async_get("sensor.test") + assert entry is not None + assert not entry.hidden + assert entry.hidden_by is None + assert device_registry.async_get(device_id) is not None + + # Mock the user hides the entity + entity_registry.async_update_entity( + "sensor.test", hidden_by=er.RegistryEntryHider.USER + ) + await hass.async_block_till_done(wait_background_tasks=True) + + # Remove the entity and device, + # the hidden by user flag should be preserved in the deleted entities registry + async_fire_mqtt_message(hass, discovery_topic, "") + await hass.async_block_till_done(wait_background_tasks=True) + entry = entity_registry.async_get("sensor.test") + assert entry is None + # Assert device is cleaned up + assert device_registry.async_get(device_id) is None + + # Rediscover again and assert the entity remains hidden + # because it was hidden by the user, even though visible_by_default is True + async_fire_mqtt_message(hass, discovery_topic, config_visible) + await hass.async_block_till_done() + state = hass.states.get("sensor.test") + assert state is not None + entry = entity_registry.async_get("sensor.test") + assert entry is not None + assert entry.hidden + assert entry.hidden_by is er.RegistryEntryHider.USER + assert device_registry.async_get(device_id) is not None + assert ( + "Restored entity sensor.test was configured as visible by default, " + "but was hidden by the user before, and will remain hidden" in caplog.text + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "unique_id": "very_unique", + } + } + }, + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "visible_by_default": True, + "unique_id": "very_unique", + } + } + }, + ], +) +async def test_registry_visible_by_default( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + entity_registry: er.EntityRegistry, +) -> None: + """Test an entity is visible with visible_by_default set or without.""" + await mqtt_mock_entry() + state = hass.states.get("sensor.test") + assert state is not None + entry = entity_registry.async_get("sensor.test") + assert not entry.disabled + assert entry.hidden is False + assert entry.hidden_by is None + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "visible_by_default": False, + "unique_id": "very_unique", + } + } + }, + ], +) +async def test_registry_not_visible_by_default( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + entity_registry: er.EntityRegistry, +) -> None: + """Test an entity that is not visible by default.""" + await mqtt_mock_entry() + state = hass.states.get("sensor.test") + assert state is not None + entry = entity_registry.async_get("sensor.test") + assert entry.hidden is True + assert entry.hidden_by is er.RegistryEntryHider.INTEGRATION + + @pytest.mark.parametrize( "mqtt_config_subentries_data", [ From 64a68f38f05588673acfb2e0107d2e07f6d75589 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:27:25 +0200 Subject: [PATCH 101/404] Bump github/codeql-action from 4.36.1 to 4.36.2 (#173490) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 214e2569b34d..e9b4bef674e5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,11 +28,11 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: category: "/language:python" From c0b5dec23b0cbf579f4d6cb1df93c1e0ee243ef0 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 11 Jun 2026 10:30:54 +0200 Subject: [PATCH 102/404] Revert "Unify query token auth in http views" (#173466) --- homeassistant/components/brands/__init__.py | 28 +++++--- homeassistant/components/camera/__init__.py | 32 +++++---- homeassistant/components/image/__init__.py | 42 ++++++----- .../components/media_player/__init__.py | 33 +++++---- homeassistant/helpers/http.py | 18 +---- tests/components/brands/test_init.py | 14 ++-- tests/components/camera/test_init.py | 24 ------- tests/components/http/test_ban.py | 19 +---- tests/components/http/test_view.py | 69 +++---------------- tests/components/image/test_init.py | 12 +--- tests/components/media_player/test_init.py | 22 ------ 11 files changed, 101 insertions(+), 212 deletions(-) diff --git a/homeassistant/components/brands/__init__.py b/homeassistant/components/brands/__init__.py index a0eb29805545..19a6a93e83c2 100644 --- a/homeassistant/components/brands/__init__.py +++ b/homeassistant/components/brands/__init__.py @@ -1,19 +1,18 @@ """The Brands integration.""" from collections import deque -from collections.abc import Container, Mapping from http import HTTPStatus import logging from pathlib import Path from random import SystemRandom import time -from typing import Any, Final, override +from typing import Any, Final -from aiohttp import ClientError, web +from aiohttp import ClientError, hdrs, web import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.core import HomeAssistant, callback, valid_domain from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -109,18 +108,23 @@ def _read_brand_file(brand_dir: Path, image: str) -> bytes | None: class _BrandsBaseView(HomeAssistantView): """Base view for serving brand images.""" - use_query_token_for_auth = True + requires_auth = False def __init__(self, hass: HomeAssistant) -> None: """Initialize the view.""" self._hass = hass self._cache_dir = Path(hass.config.cache_path(DOMAIN)) - @callback - @override - def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]: - """Return valid auth tokens, which can be used for query token authentication.""" - return self._hass.data[DOMAIN] + def _authenticate(self, request: web.Request) -> None: + """Authenticate the request using Bearer token or query token.""" + access_tokens: deque[str] = self._hass.data[DOMAIN] + authenticated = ( + request[KEY_AUTHENTICATED] or request.query.get("token") in access_tokens + ) + if not authenticated: + if hdrs.AUTHORIZATION in request.headers: + raise web.HTTPUnauthorized + raise web.HTTPForbidden async def _serve_from_custom_integration( self, @@ -236,6 +240,8 @@ class BrandsIntegrationView(_BrandsBaseView): image: str, ) -> web.Response: """Handle GET request for an integration brand image.""" + self._authenticate(request) + if not valid_domain(domain) or image not in ALLOWED_IMAGES: return web.Response(status=HTTPStatus.NOT_FOUND) @@ -268,6 +274,8 @@ class BrandsHardwareView(_BrandsBaseView): image: str, ) -> web.Response: """Handle GET request for a hardware brand image.""" + self._authenticate(request) + if not CATEGORY_RE.match(category): return web.Response(status=HTTPStatus.NOT_FOUND) # Hardware images have dynamic names like "manufacturer_model.png" diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index fee4b6c20c76..77cb8100e33b 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -2,7 +2,7 @@ import asyncio import collections -from collections.abc import Awaitable, Callable, Container, Coroutine, Mapping +from collections.abc import Awaitable, Callable, Coroutine from contextlib import suppress from dataclasses import asdict, dataclass from datetime import datetime, timedelta @@ -12,16 +12,16 @@ import logging import os from random import SystemRandom import time -from typing import Any, Final, final, override +from typing import Any, Final, final -from aiohttp import web +from aiohttp import hdrs, web import attr from propcache.api import cached_property, under_cached_property import voluptuous as vol from webrtc_models import RTCIceCandidateInit from homeassistant.components import websocket_api -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, @@ -776,26 +776,30 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): class CameraView(HomeAssistantView): """Base CameraView.""" - use_query_token_for_auth = True + requires_auth = False def __init__(self, component: EntityComponent[Camera]) -> None: """Initialize a basic camera view.""" self.component = component - @callback - @override - def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]: - """Return valid auth tokens, which can be used for query token authentication.""" - if (camera := self.component.get_entity(match_info["entity_id"])) is None: - return () - - return camera.access_tokens - async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse: """Start a GET request.""" if (camera := self.component.get_entity(entity_id)) is None: raise web.HTTPNotFound + authenticated = ( + request[KEY_AUTHENTICATED] + or request.query.get("token") in camera.access_tokens + ) + + if not authenticated: + # Attempt with invalid bearer token, raise unauthorized + # so ban middleware can handle it. + if hdrs.AUTHORIZATION in request.headers: + raise web.HTTPUnauthorized + # Invalid sigAuth or camera access token + raise web.HTTPForbidden + if not camera.is_on: _LOGGER.debug("Camera is off") raise web.HTTPServiceUnavailable diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index 91ef6aab79bd..f1f76003f5a2 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -2,21 +2,20 @@ import asyncio import collections -from collections.abc import Container, Mapping from contextlib import suppress from dataclasses import dataclass from datetime import datetime, timedelta import logging import os from random import SystemRandom -from typing import Final, final, override +from typing import Final, final -from aiohttp import web +from aiohttp import hdrs, web import httpx from propcache.api import cached_property import voluptuous as vol -from homeassistant.components.http import KEY_HASS, HomeAssistantView +from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STOP from homeassistant.core import ( @@ -315,28 +314,33 @@ class ImageView(HomeAssistantView): """View to serve an image.""" name = "api:image:image" - use_query_token_for_auth = True + requires_auth = False url = "/api/image_proxy/{entity_id}" def __init__(self, component: EntityComponent[ImageEntity]) -> None: """Initialize an image view.""" self.component = component - @callback - @override - def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]: - """Return valid auth tokens, which can be used for query token authentication.""" - if (image_entity := self.component.get_entity(match_info["entity_id"])) is None: - return () - - return image_entity.access_tokens - - @callback - def _get_image_entity(self, entity_id: str) -> ImageEntity: - """Get image entity from request.""" + async def _authenticate_request( + self, request: web.Request, entity_id: str + ) -> ImageEntity: + """Authenticate request and return image entity.""" if (image_entity := self.component.get_entity(entity_id)) is None: raise web.HTTPNotFound + authenticated = ( + request[KEY_AUTHENTICATED] + or request.query.get("token") in image_entity.access_tokens + ) + + if not authenticated: + # Attempt with invalid bearer token, raise unauthorized + # so ban middleware can handle it. + if hdrs.AUTHORIZATION in request.headers: + raise web.HTTPUnauthorized + # Invalid sigAuth or image entity access token + raise web.HTTPForbidden + return image_entity async def head(self, request: web.Request, entity_id: str) -> web.Response: @@ -345,7 +349,7 @@ class ImageView(HomeAssistantView): This is sent by some DLNA renderers, like Samsung ones, prior to sending the GET request. """ - image_entity = self._get_image_entity(entity_id) + image_entity = await self._authenticate_request(request, entity_id) # Don't use `handle` as we don't care about the stream case, we only want # to verify that the image exists. @@ -361,7 +365,7 @@ class ImageView(HomeAssistantView): async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse: """Start a GET request.""" - image_entity = self._get_image_entity(entity_id) + image_entity = await self._authenticate_request(request, entity_id) return await self.handle(request, image_entity) async def handle( diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 1a17e7595b9d..03abd54d7f03 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -2,7 +2,7 @@ import asyncio import collections -from collections.abc import Callable, Container, Mapping +from collections.abc import Callable from contextlib import suppress import datetime as dt from enum import StrEnum @@ -12,7 +12,7 @@ import hashlib from http import HTTPStatus import logging import secrets -from typing import Any, Final, Required, TypedDict, final, override +from typing import Any, Final, Required, TypedDict, final from urllib.parse import quote, urlparse import aiohttp @@ -24,7 +24,7 @@ import voluptuous as vol from yarl import URL from homeassistant.components import websocket_api -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.websocket_api import ERR_NOT_SUPPORTED, ERR_UNKNOWN_ERROR from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( # noqa: F401 @@ -50,7 +50,7 @@ from homeassistant.const import ( # noqa: F401 STATE_PLAYING, STATE_STANDBY, ) -from homeassistant.core import HomeAssistant, SupportsResponse, callback +from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import Entity, EntityDescription @@ -1249,7 +1249,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): class MediaPlayerImageView(HomeAssistantView): """Media player view to serve an image.""" - use_query_token_for_auth = True + requires_auth = False url = "/api/media_player_proxy/{entity_id}" name = "api:media_player:image" extra_urls = [ @@ -1262,15 +1262,6 @@ class MediaPlayerImageView(HomeAssistantView): """Initialize a media player view.""" self.component = component - @callback - @override - def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]: - """Return valid auth tokens, which can be used for query token authentication.""" - if (player := self.component.get_entity(match_info["entity_id"])) is None: - return () - - return (player.access_token,) - async def get( self, request: web.Request, @@ -1280,9 +1271,21 @@ class MediaPlayerImageView(HomeAssistantView): ) -> web.Response: """Start a get request.""" if (player := self.component.get_entity(entity_id)) is None: - return web.Response(status=HTTPStatus.NOT_FOUND) + status = ( + HTTPStatus.NOT_FOUND + if request[KEY_AUTHENTICATED] + else HTTPStatus.UNAUTHORIZED + ) + return web.Response(status=status) assert isinstance(player, MediaPlayerEntity) + authenticated = ( + request[KEY_AUTHENTICATED] + or request.query.get("token") == player.access_token + ) + + if not authenticated: + return web.Response(status=HTTPStatus.UNAUTHORIZED) if media_content_type and media_content_id: media_image_id = request.query.get("media_image_id") diff --git a/homeassistant/helpers/http.py b/homeassistant/helpers/http.py index 1d300133e759..0f732ed62b96 100644 --- a/homeassistant/helpers/http.py +++ b/homeassistant/helpers/http.py @@ -1,6 +1,6 @@ """Helper to track the current http request.""" -from collections.abc import Awaitable, Callable, Container, Mapping +from collections.abc import Awaitable, Callable from contextvars import ContextVar from http import HTTPStatus import inspect @@ -20,7 +20,7 @@ import voluptuous as vol from homeassistant import exceptions from homeassistant.const import CONTENT_TYPE_JSON -from homeassistant.core import Context, HomeAssistant, callback, is_callback +from homeassistant.core import Context, HomeAssistant, is_callback from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS, format_unserializable_data from .json import find_paths_unserializable_data, json_bytes, json_dumps @@ -55,13 +55,7 @@ def request_handler_factory( authenticated = request.get(KEY_AUTHENTICATED, False) - if view.use_query_token_for_auth and not authenticated: - token = request.query.get("token") - if token and token in view.get_valid_auth_tokens(request.match_info): - _LOGGER.debug("Authenticated request with query token") - authenticated = True - - if (view.requires_auth or view.use_query_token_for_auth) and not authenticated: + if view.requires_auth and not authenticated: # Import here to avoid circular dependency with network.py from .network import NoURLAvailableError, get_url # noqa: PLC0415 @@ -135,7 +129,6 @@ class HomeAssistantView: extra_urls: list[str] = [] # Views inheriting from this class can override this requires_auth = True - use_query_token_for_auth = False cors_allowed = False @staticmethod @@ -211,8 +204,3 @@ class HomeAssistantView: if allow_cors: for route in routes: allow_cors(route) - - @callback - def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]: - """Return valid auth tokens, which can be used for query token authentication.""" - return () diff --git a/tests/components/brands/test_init.py b/tests/components/brands/test_init.py index f2eda343081d..5e13a9bf909d 100644 --- a/tests/components/brands/test_init.py +++ b/tests/components/brands/test_init.py @@ -809,30 +809,30 @@ async def test_token_query_param_authentication( assert await resp.read() == FAKE_PNG -async def test_unauthenticated_request_unauthorized( +async def test_unauthenticated_request_forbidden( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, ) -> None: - """Test that unauthenticated requests are unauthorized.""" + """Test that unauthenticated requests are forbidden.""" client = await hass_client_no_auth() resp = await client.get("/api/brands/integration/hue/icon.png") - assert resp.status == HTTPStatus.UNAUTHORIZED + assert resp.status == HTTPStatus.FORBIDDEN resp = await client.get("/api/brands/hardware/boards/green.png") - assert resp.status == HTTPStatus.UNAUTHORIZED + assert resp.status == HTTPStatus.FORBIDDEN -async def test_invalid_token_unauthorized( +async def test_invalid_token_forbidden( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, ) -> None: - """Test that an invalid access token in query param is unauthorized.""" + """Test that an invalid access token in query param is forbidden.""" client = await hass_client_no_auth() resp = await client.get("/api/brands/integration/hue/icon.png?token=invalid_token") - assert resp.status == HTTPStatus.UNAUTHORIZED + assert resp.status == HTTPStatus.FORBIDDEN async def test_invalid_bearer_token_unauthorized( diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index dc873364268d..77a26b0642c0 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -691,30 +691,6 @@ async def test_camera_proxy_stream(hass_client: ClientSessionGenerator) -> None: assert response.status == HTTPStatus.BAD_GATEWAY -@pytest.mark.usefixtures("mock_camera") -async def test_camera_proxy_query_token_auth( - hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator -) -> None: - """Test the camera proxy authenticates via the access token query param.""" - client = await hass_client_no_auth() - - state = hass.states.get("camera.demo_camera") - assert state is not None - - # A valid access token in the query param authenticates the request - resp = await client.get(state.attributes["entity_picture"]) - assert resp.status == HTTPStatus.OK - assert await resp.read() == b"Test" - - # Without a token the request is unauthorized - resp = await client.get("/api/camera_proxy/camera.demo_camera") - assert resp.status == HTTPStatus.UNAUTHORIZED - - # An invalid token is also unauthorized - resp = await client.get("/api/camera_proxy/camera.demo_camera?token=invalid") - assert resp.status == HTTPStatus.UNAUTHORIZED - - @pytest.mark.usefixtures("mock_camera") async def test_state_streaming(hass: HomeAssistant) -> None: """Camera state.""" diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index 149067eeb858..086adb99dca9 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -405,27 +405,14 @@ async def test_failed_login_attempts_counter( app.router.add_get( "/auth_true", - request_handler_factory( - hass, - Mock(requires_auth=True, use_query_token_for_auth=False), - auth_true_handler, - ), + request_handler_factory(hass, Mock(requires_auth=True), auth_true_handler), ) app.router.add_get( "/auth_false", - request_handler_factory( - hass, - Mock(requires_auth=True, use_query_token_for_auth=False), - auth_handler, - ), + request_handler_factory(hass, Mock(requires_auth=True), auth_handler), ) app.router.add_get( - "/", - request_handler_factory( - hass, - Mock(requires_auth=False, use_query_token_for_auth=False), - auth_handler, - ), + "/", request_handler_factory(hass, Mock(requires_auth=False), auth_handler) ) setup_bans(hass, app, 5) diff --git a/tests/components/http/test_view.py b/tests/components/http/test_view.py index ef27938585b3..fca811f6bbf3 100644 --- a/tests/components/http/test_view.py +++ b/tests/components/http/test_view.py @@ -61,7 +61,7 @@ async def test_handling_unauthorized(mock_request: Mock) -> None: with pytest.raises(HTTPUnauthorized): await request_handler_factory( mock_request.app[KEY_HASS], - Mock(requires_auth=False, use_query_token_for_auth=False), + Mock(requires_auth=False), AsyncMock(side_effect=Unauthorized), )(mock_request) @@ -71,7 +71,7 @@ async def test_handling_invalid_data(mock_request: Mock) -> None: with pytest.raises(HTTPBadRequest): await request_handler_factory( mock_request.app[KEY_HASS], - Mock(requires_auth=False, use_query_token_for_auth=False), + Mock(requires_auth=False), AsyncMock(side_effect=vol.Invalid("yo")), )(mock_request) @@ -81,7 +81,7 @@ async def test_handling_service_not_found(mock_request: Mock) -> None: with pytest.raises(HTTPInternalServerError): await request_handler_factory( mock_request.app[KEY_HASS], - Mock(requires_auth=False, use_query_token_for_auth=False), + Mock(requires_auth=False), AsyncMock(side_effect=ServiceNotFound("test", "test")), )(mock_request) @@ -90,7 +90,7 @@ async def test_not_running(mock_request_with_stopping: Mock) -> None: """Test we get a 503 when not running.""" response = await request_handler_factory( mock_request_with_stopping.app[KEY_HASS], - Mock(requires_auth=False, use_query_token_for_auth=False), + Mock(requires_auth=False), AsyncMock(side_effect=Unauthorized), )(mock_request_with_stopping) assert response.status == HTTPStatus.SERVICE_UNAVAILABLE @@ -101,64 +101,11 @@ async def test_invalid_handler(mock_request: Mock) -> None: with pytest.raises(TypeError): await request_handler_factory( mock_request.app[KEY_HASS], - Mock(requires_auth=False, use_query_token_for_auth=False), + Mock(requires_auth=False), AsyncMock(return_value=["not valid"]), )(mock_request) -async def test_query_token_auth_valid(mock_request: Mock) -> None: - """Test authentication with a valid query token.""" - mock_request.get = Mock(return_value=False) - mock_request.query = {"token": "valid-token"} - handler = AsyncMock(return_value=None) - - response = await request_handler_factory( - mock_request.app[KEY_HASS], - Mock( - requires_auth=False, - use_query_token_for_auth=True, - get_valid_auth_tokens=Mock(return_value={"valid-token"}), - ), - handler, - )(mock_request) - - assert response.status == HTTPStatus.OK - handler.assert_awaited_once() - - -@pytest.mark.parametrize( - "query", - [{"token": "wrong-token"}, {}], - ids=["invalid_token", "missing_token"], -) -async def test_query_token_auth_unauthorized( - mock_request: Mock, query: dict[str, str] -) -> None: - """Test an invalid or missing query token is rejected.""" - mock_request.get = Mock(return_value=False) - mock_request.query = query - handler = AsyncMock() - - with ( - patch( - "homeassistant.helpers.network.get_url", - return_value="https://example.com", - ), - pytest.raises(HTTPUnauthorized), - ): - await request_handler_factory( - mock_request.app[KEY_HASS], - Mock( - requires_auth=False, - use_query_token_for_auth=True, - get_valid_auth_tokens=Mock(return_value={"valid-token"}), - ), - handler, - )(mock_request) - - handler.assert_not_awaited() - - async def test_requires_auth_includes_www_authenticate( mock_request: Mock, ) -> None: @@ -173,7 +120,7 @@ async def test_requires_auth_includes_www_authenticate( ): await request_handler_factory( mock_request.app[KEY_HASS], - Mock(requires_auth=True, use_query_token_for_auth=False), + Mock(requires_auth=True), AsyncMock(), )(mock_request) assert exc_info.value.headers["WWW-Authenticate"] == ( @@ -196,7 +143,7 @@ async def test_requires_auth_omits_www_authenticate_without_url( ): await request_handler_factory( mock_request.app[KEY_HASS], - Mock(requires_auth=True, use_query_token_for_auth=False), + Mock(requires_auth=True), AsyncMock(), )(mock_request) assert "WWW-Authenticate" not in exc_info.value.headers @@ -265,7 +212,7 @@ async def test_requires_auth_www_authenticate_prefer_external( with pytest.raises(HTTPUnauthorized) as exc_info: await request_handler_factory( hass, - Mock(requires_auth=True, use_query_token_for_auth=False), + Mock(requires_auth=True), AsyncMock(), )(mock_current_request) diff --git a/tests/components/image/test_init.py b/tests/components/image/test_init.py index 1b76308b9f2c..8bb057050556 100644 --- a/tests/components/image/test_init.py +++ b/tests/components/image/test_init.py @@ -234,30 +234,24 @@ async def test_fetch_image_unauthenticated( client = await hass_client_no_auth() resp = await client.get("/api/image_proxy/image.test") - assert resp.status == HTTPStatus.UNAUTHORIZED + assert resp.status == HTTPStatus.FORBIDDEN resp = await client.get("/api/image_proxy/image.test") - assert resp.status == HTTPStatus.UNAUTHORIZED + assert resp.status == HTTPStatus.FORBIDDEN resp = await client.get( "/api/image_proxy/image.test", headers={hdrs.AUTHORIZATION: "blabla"} ) assert resp.status == HTTPStatus.UNAUTHORIZED - # An invalid token is also unauthorized - resp = await client.get("/api/image_proxy/image.test?token=invalid") - assert resp.status == HTTPStatus.UNAUTHORIZED - state = hass.states.get("image.test") resp = await client.get(state.attributes["entity_picture"]) assert resp.status == HTTPStatus.OK body = await resp.read() assert body == b"Test" - # Unknown entities are also unauthorized for an unauthenticated client, so - # their existence is not leaked resp = await client.get("/api/image_proxy/image.unknown") - assert resp.status == HTTPStatus.UNAUTHORIZED + assert resp.status == HTTPStatus.NOT_FOUND @respx.mock diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index c5ef28a1f80a..2ab73fe30575 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -112,28 +112,6 @@ async def test_get_image_http( assert content == b"image" -async def test_get_image_http_unauthenticated( - hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator -) -> None: - """Test get image via http command without a valid token is unauthorized.""" - await async_setup_component( - hass, "media_player", {"media_player": {"platform": "demo"}} - ) - await hass.async_block_till_done() - - client = await hass_client_no_auth() - - # Without a token the request is unauthorized - resp = await client.get("/api/media_player_proxy/media_player.bedroom") - assert resp.status == HTTPStatus.UNAUTHORIZED - - # An invalid token is also unauthorized - resp = await client.get( - "/api/media_player_proxy/media_player.bedroom?token=invalid" - ) - assert resp.status == HTTPStatus.UNAUTHORIZED - - async def test_get_image_http_remote( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator ) -> None: From a2477d71fb6deeda4a81cfd85a40273680ff73e8 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Thu, 11 Jun 2026 09:57:40 +0100 Subject: [PATCH 103/404] Bump pytrydan to 1.0.2 (#173479) --- homeassistant/components/v2c/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/v2c/manifest.json b/homeassistant/components/v2c/manifest.json index 1ebedd49eb19..459a3260b6d3 100644 --- a/homeassistant/components/v2c/manifest.json +++ b/homeassistant/components/v2c/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/v2c", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pytrydan==1.0.1"] + "requirements": ["pytrydan==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index af5ba5c5505f..762cf7ee7a2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2794,7 +2794,7 @@ pytradfri[async]==9.0.1 pytrafikverket==1.1.1 # homeassistant.components.v2c -pytrydan==1.0.1 +pytrydan==1.0.2 # homeassistant.components.uptimerobot pyuptimerobot==25.0.0 From 0d67cc0795a8a9764c76ebb8172c48e3166940eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Thu, 11 Jun 2026 12:00:41 +0200 Subject: [PATCH 104/404] Add reconfigure flow to aqvify (#173355) Co-authored-by: Joost Lekkerkerker Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/aqvify/config_flow.py | 17 +++++++++- homeassistant/components/aqvify/strings.json | 1 + tests/components/aqvify/test_config_flow.py | 33 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/aqvify/config_flow.py b/homeassistant/components/aqvify/config_flow.py index 64273e35190d..263caa4f1159 100644 --- a/homeassistant/components/aqvify/config_flow.py +++ b/homeassistant/components/aqvify/config_flow.py @@ -8,7 +8,11 @@ from aiohttp import ClientResponseError from pyaqvify import AqvifyAPI, AqvifyAuthException import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_API_KEY from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -49,6 +53,11 @@ class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: await self.async_set_unique_id(account_data.account_id) + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), data_updates=user_input + ) self._abort_if_unique_id_configured() return self.async_create_entry(title="Aqvify", data=user_input) @@ -96,3 +105,9 @@ class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=STEP_USER_DATA_SCHEMA, errors=errors, ) + + async def async_step_reconfigure( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """User initiated reconfiguration.""" + return await self.async_step_user() diff --git a/homeassistant/components/aqvify/strings.json b/homeassistant/components/aqvify/strings.json index dace067c0b00..88c63f706e11 100644 --- a/homeassistant/components/aqvify/strings.json +++ b/homeassistant/components/aqvify/strings.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unique_id_mismatch": "The entered API key corresponds to a different account." }, "error": { diff --git a/tests/components/aqvify/test_config_flow.py b/tests/components/aqvify/test_config_flow.py index ba2a48d99be6..1131e027e652 100644 --- a/tests/components/aqvify/test_config_flow.py +++ b/tests/components/aqvify/test_config_flow.py @@ -196,3 +196,36 @@ async def test_reauth_flow_error( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("return_value", "expected_reason"), + [ + ("test_account_id", "reconfigure_successful"), + ("test_account_different_id", "unique_id_mismatch"), + ], + ids=["same_account", "different_account"], +) +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_aqvify_client: MagicMock, + return_value: str, + expected_reason: str, +) -> None: + """Test reconfiguration.""" + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_aqvify_client.async_get_account_id.return_value = AqvifyAccount( + {"accountId": return_value} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: "fake-api-key"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_reason From 00a48df8cb886190c15f8bf3d245e3dfe85a06f3 Mon Sep 17 00:00:00 2001 From: Tom Matheussen <13683094+Tommatheussen@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:33:42 +0200 Subject: [PATCH 105/404] Fix Satel Integra arm home mode selection (#173431) --- .../components/satel_integra/config_flow.py | 14 ++++++++++++-- homeassistant/components/satel_integra/const.py | 2 +- .../components/satel_integra/strings.json | 9 ++++++++- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/satel_integra/config_flow.py b/homeassistant/components/satel_integra/config_flow.py index fe20a5105605..e9e413060828 100644 --- a/homeassistant/components/satel_integra/config_flow.py +++ b/homeassistant/components/satel_integra/config_flow.py @@ -63,11 +63,21 @@ CODE_SCHEMA = vol.Schema( } ) +ARM_HOME_MODE_OPTIONS = ["1", "2", "3"] + PARTITION_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_ARM_HOME_MODE, default=DEFAULT_CONF_ARM_HOME_MODE): vol.In( - [1, 2, 3] + vol.Required(CONF_ARM_HOME_MODE, default=DEFAULT_CONF_ARM_HOME_MODE): vol.All( + vol.Coerce(str), + selector.SelectSelector( + selector.SelectSelectorConfig( + options=ARM_HOME_MODE_OPTIONS, + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="arm_home_mode", + ) + ), + vol.Coerce(int), ), } ) diff --git a/homeassistant/components/satel_integra/const.py b/homeassistant/components/satel_integra/const.py index 929b8f27d093..8c7b7eed1759 100644 --- a/homeassistant/components/satel_integra/const.py +++ b/homeassistant/components/satel_integra/const.py @@ -1,6 +1,6 @@ """Constants for the Satel Integra integration.""" -DEFAULT_CONF_ARM_HOME_MODE = 1 +DEFAULT_CONF_ARM_HOME_MODE = "1" DEFAULT_PORT = 7094 DOMAIN = "satel_integra" diff --git a/homeassistant/components/satel_integra/strings.json b/homeassistant/components/satel_integra/strings.json index 4d40282b536d..a85930874883 100644 --- a/homeassistant/components/satel_integra/strings.json +++ b/homeassistant/components/satel_integra/strings.json @@ -113,7 +113,7 @@ "partition_number": "Partition number" }, "data_description": { - "arm_home_mode": "The mode in which the partition is armed when 'arm home' is used. For more information on what the differences are between them, please refer to Satel Integra manual.", + "arm_home_mode": "The arming mode to use for 'arm home':\nMode 1 fully arms and bypasses zones that have the 'Bypassed if no exit' option enabled.\nMode 2 disarms interior zones; exterior zones trigger silent alarms and other alarm zones trigger loud alarms.\nMode 3 is like mode 2, but delayed zones are instant.", "name": "The name to give to the alarm panel", "partition_number": "Enter partition number to configure" }, @@ -223,6 +223,13 @@ } }, "selector": { + "arm_home_mode": { + "options": { + "1": "1 - Full arming + bypasses", + "2": "2 - No interior zones", + "3": "3 - No interior zones or entry delay" + } + }, "binary_sensor_device_class": { "options": { "battery": "[%key:component::binary_sensor::entity_component::battery::name%]", From ac5e1f178b231509439d63d9d392a2a641874426 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:27:49 +0200 Subject: [PATCH 106/404] Use parse_module helper in pylint import checker (visit_import) (#173088) --- .../pylint_home_assistant/checkers/imports.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pylint/plugins/pylint_home_assistant/checkers/imports.py b/pylint/plugins/pylint_home_assistant/checkers/imports.py index bd41e9e1739a..d94d06074ca5 100644 --- a/pylint/plugins/pylint_home_assistant/checkers/imports.py +++ b/pylint/plugins/pylint_home_assistant/checkers/imports.py @@ -8,6 +8,7 @@ from pylint.checkers import BaseChecker from pylint.lint import PyLinter from pylint_home_assistant.const import Module +from pylint_home_assistant.helpers.module_info import parse_module @dataclass @@ -230,17 +231,16 @@ class HassImportsFormatChecker(BaseChecker): """Check for improper `import _` invocations.""" if self.current_package is None: return - for module, _alias in node.names: - if module.startswith(f"{self.current_package}."): + for other_module, _alias in node.names: + if other_module.startswith(f"{self.current_package}."): self.add_message("home-assistant-relative-import", node=node) continue if ( - module.startswith("homeassistant.components.") - and len(module.split(".")) > 3 - ): + other_parsed := parse_module(other_module) + ) and other_parsed.module is not None: if ( self.current_package.startswith("tests.components.") - and self.current_package.split(".")[2] == module.split(".")[2] + and self.current_package.split(".")[2] == other_parsed.domain ): # Ignore check if the component being tested matches # the component being imported from From c83323894c9dec6ff0100af65a1d5e7d9df6e0bb Mon Sep 17 00:00:00 2001 From: Manu Date: Thu, 11 Jun 2026 13:47:55 +0200 Subject: [PATCH 107/404] Add reconfiguration flow to SMTP integration (#173376) --- homeassistant/components/smtp/config_flow.py | 31 ++++ homeassistant/components/smtp/strings.json | 26 ++- tests/components/smtp/test_config_flow.py | 173 +++++++++++++++++++ 3 files changed, 229 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smtp/config_flow.py b/homeassistant/components/smtp/config_flow.py index b9da23602d80..3de349c67660 100644 --- a/homeassistant/components/smtp/config_flow.py +++ b/homeassistant/components/smtp/config_flow.py @@ -170,6 +170,37 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): ) return result + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfigure flow.""" + errors: dict[str, str] = {} + + entry = self._get_reconfigure_entry() + + if user_input is not None: + self._async_abort_entries_match( + { + CONF_SERVER: user_input[CONF_SERVER], + CONF_SENDER: user_input[CONF_SENDER], + CONF_USERNAME: user_input.get(CONF_USERNAME), + } + ) + errors = await self.hass.async_add_executor_job(validate_input, user_input) + if not errors: + return self.async_update_and_abort( + entry, + data=user_input, + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, + suggested_values=user_input or entry.data, + ), + errors=errors, + ) + async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult: """Import config from yaml.""" diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index 849e78ce457a..40242c20ffce 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -10,6 +11,29 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reconfigure": { + "data": { + "encryption": "[%key:component::smtp::config::step::user::data::encryption%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "sender": "[%key:component::smtp::config::step::user::data::sender%]", + "sender_name": "[%key:component::smtp::config::step::user::data::sender_name%]", + "server": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "encryption": "[%key:component::smtp::config::step::user::data_description::encryption%]", + "password": "[%key:component::smtp::config::step::user::data_description::password%]", + "port": "[%key:component::smtp::config::step::user::data_description::port%]", + "sender": "[%key:component::smtp::config::step::user::data_description::sender%]", + "sender_name": "[%key:component::smtp::config::step::user::data_description::sender_name%]", + "server": "[%key:component::smtp::config::step::user::data_description::server%]", + "username": "[%key:component::smtp::config::step::user::data_description::username%]", + "verify_ssl": "[%key:component::smtp::config::step::user::data_description::verify_ssl%]" + }, + "title": "Reconfigure SMTP" + }, "user": { "data": { "encryption": "Connection security", diff --git a/tests/components/smtp/test_config_flow.py b/tests/components/smtp/test_config_flow.py index ca82c0d90514..8e988ba8d2e5 100644 --- a/tests/components/smtp/test_config_flow.py +++ b/tests/components/smtp/test_config_flow.py @@ -252,3 +252,176 @@ async def test_options_flow( assert config_entry.options == { CONF_TIMEOUT: 10, } + + +@pytest.mark.usefixtures("smtp") +async def test_form_reconfigure( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow.""" + + config_entry.add_to_hass(hass) + + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "New sender name", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + CONF_VERIFY_SSL: True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert config_entry.data == { + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "New sender name", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + CONF_VERIFY_SSL: True, + } + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("smtp") +async def test_form_reconfigure_already_configured( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow already configured.""" + + MockConfigEntry( + domain=DOMAIN, + title="Home Assistant", + data={ + CONF_SENDER: "already_configured@example.com", + CONF_SENDER_NAME: "Home Assistant", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_VERIFY_SSL: True, + }, + entry_id="987654321", + ).add_to_hass(hass) + + config_entry.add_to_hass(hass) + + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SENDER: "already_configured@example.com", + CONF_SENDER_NAME: "Home Assistant", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_VERIFY_SSL: True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert len(hass.config_entries.async_entries()) == 2 + + +@pytest.mark.parametrize( + ("exception", "text_error"), + [ + (SMTPAuthenticationError(0, ""), "invalid_auth"), + (ConnectionRefusedError, "cannot_connect"), + (gaierror, "cannot_connect"), + (SSLCertVerificationError, "invalid_cert"), + (ValueError, "unknown"), + ], +) +@pytest.mark.usefixtures("smtp") +async def test_form_reconfigure_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + smtp: MagicMock, + exception: Exception, + text_error: str, +) -> None: + """Test reconfigure flow connection errors.""" + + smtp.login.side_effect = exception + + config_entry.add_to_hass(hass) + + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "New sender name", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + CONF_VERIFY_SSL: True, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + smtp.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "New sender name", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + CONF_VERIFY_SSL: True, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert config_entry.data == { + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "New sender name", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + CONF_VERIFY_SSL: True, + } + assert len(hass.config_entries.async_entries()) == 1 From 1b582f4089a626b4e1f690a29ccae45ebe8c4daa Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:56:53 +0200 Subject: [PATCH 108/404] Use parse_module helper in pylint import checker (visit_importfrom) (#173375) Co-authored-by: Markus Tuominen <3738613+Markus98@users.noreply.github.com> --- .../pylint_home_assistant/checkers/imports.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/pylint/plugins/pylint_home_assistant/checkers/imports.py b/pylint/plugins/pylint_home_assistant/checkers/imports.py index d94d06074ca5..defcc97f6581 100644 --- a/pylint/plugins/pylint_home_assistant/checkers/imports.py +++ b/pylint/plugins/pylint_home_assistant/checkers/imports.py @@ -314,18 +314,18 @@ class HassImportsFormatChecker(BaseChecker): self, node: nodes.ImportFrom, current_component: str | None, - imported_parts: list[str], - imported_component: str, + other_component: str, + other_module: str | None, ) -> bool: """Check for hass-component-root-import.""" if ( - current_component == imported_component - or imported_component in _IGNORE_ROOT_IMPORT + current_component == other_component + or other_component in _IGNORE_ROOT_IMPORT ): return True # Check for `from homeassistant.components.other.module import something` - if len(imported_parts) > 3: + if other_module is not None: self.add_message("home-assistant-component-root-import", node=node) return False @@ -385,19 +385,16 @@ class HassImportsFormatChecker(BaseChecker): ): return - if node.modname.startswith("homeassistant.components."): - imported_parts = node.modname.split(".") - imported_component = imported_parts[2] - + if other_parsed := parse_module(node.modname): # Checks for hass-component-root-import if not self._check_for_component_root_import( - node, current_component, imported_parts, imported_component + node, current_component, other_parsed.domain, other_parsed.module ): return # Checks for hass-import-constant-alias if not self._check_for_constant_alias( - node, current_component, imported_component + node, current_component, other_parsed.domain ): return From a25a55737fecef6c034f406cac3299af4f3ba24a Mon Sep 17 00:00:00 2001 From: Ken Schulz <2052672+kwschulz@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:31:44 -0400 Subject: [PATCH 109/404] Handle read timeouts in google_wifi sensor update (#173511) Co-authored-by: Claude Fable 5 --- .../components/google_wifi/sensor.py | 4 +- tests/components/google_wifi/test_sensor.py | 67 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py index 9da1a9089a22..0294fd04875d 100644 --- a/homeassistant/components/google_wifi/sensor.py +++ b/homeassistant/components/google_wifi/sensor.py @@ -155,7 +155,7 @@ class GoogleWifiSensor(SensorEntity): class GoogleWifiAPI: """Get the latest data and update the states.""" - def __init__(self, host, conditions): + def __init__(self, host, conditions) -> None: """Initialize the data object.""" uri = "http://" resource = f"{uri}{host}{ENDPOINT}" @@ -182,7 +182,7 @@ class GoogleWifiAPI: self.raw_data = response.json() self.data_format() self.available = True - except ValueError, requests.exceptions.ConnectionError: + except ValueError, requests.exceptions.RequestException: _LOGGER.warning("Unable to fetch data from Google Wifi") self.available = False self.raw_data = None diff --git a/tests/components/google_wifi/test_sensor.py b/tests/components/google_wifi/test_sensor.py index cb8cd15ca5dd..824e095975fd 100644 --- a/tests/components/google_wifi/test_sensor.py +++ b/tests/components/google_wifi/test_sensor.py @@ -5,9 +5,12 @@ from http import HTTPStatus from typing import Any from unittest.mock import Mock, patch +import pytest +import requests import requests_mock from homeassistant.components.google_wifi import sensor as google_wifi +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -83,6 +86,23 @@ async def test_setup_get( assert_setup_component(6, "sensor") +async def test_setup_when_router_unreachable( + hass: HomeAssistant, requests_mock: requests_mock.Mocker +) -> None: + """Test platform setup completes when the router does not respond.""" + resource = f"http://{google_wifi.DEFAULT_HOST}{google_wifi.ENDPOINT}" + requests_mock.get(resource, exc=requests.exceptions.ReadTimeout) + assert await async_setup_component( + hass, + "sensor", + {"sensor": {"platform": "google_wifi", "monitored_conditions": ["uptime"]}}, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.google_wifi_uptime") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + def setup_api( hass: HomeAssistant | None, data: str | None, requests_mock: requests_mock.Mocker ) -> tuple[google_wifi.GoogleWifiAPI, dict[str, Any]]: @@ -213,6 +233,53 @@ def test_when_api_data_missing( assert sensor.state is None +@pytest.mark.parametrize( + "mock_kwargs", + [ + pytest.param({"exc": requests.exceptions.ReadTimeout}, id="read_timeout"), + pytest.param({"exc": requests.exceptions.ConnectTimeout}, id="connect_timeout"), + pytest.param( + {"exc": requests.exceptions.ConnectionError}, id="connection_error" + ), + pytest.param( + {"text": "not json", "status_code": HTTPStatus.OK}, id="invalid_json" + ), + ], +) +def test_update_when_request_fails( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + mock_kwargs: dict[str, Any], +) -> None: + """Test sensors become unavailable when the update fails.""" + api, sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) + assert api.available is True + requests_mock.get(f"http://localhost{google_wifi.ENDPOINT}", **mock_kwargs) + api.update(no_throttle=True) + assert api.available is False + for value in sensor_dict.values(): + sensor = value["sensor"] + sensor.update() + assert sensor.state is None + + +def test_update_recovers_after_failure( + hass: HomeAssistant, requests_mock: requests_mock.Mocker +) -> None: + """Test the API recovers once the router responds again.""" + api, sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) + resource = f"http://localhost{google_wifi.ENDPOINT}" + requests_mock.get(resource, exc=requests.exceptions.ReadTimeout) + api.update(no_throttle=True) + assert api.available is False + requests_mock.get(resource, text=MOCK_DATA, status_code=HTTPStatus.OK) + api.update(no_throttle=True) + assert api.available is True + sensor = sensor_dict[google_wifi.ATTR_UPTIME]["sensor"] + sensor.update() + assert sensor.state == 1 + + def test_update_when_unavailable( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: From a4eba86a6c6a5c679f666643318d1ab3a3064b15 Mon Sep 17 00:00:00 2001 From: fdebrus <33791533+fdebrus@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:35:16 +0200 Subject: [PATCH 110/404] Add binary_sensor platform to Vistapool (#172234) Co-authored-by: Claude --- .../components/vistapool/__init__.py | 1 + .../components/vistapool/binary_sensor.py | 255 +++ homeassistant/components/vistapool/const.py | 1 + .../components/vistapool/strings.json | 62 + .../fixtures/pool_data_all_modules.json | 131 ++ .../snapshots/test_binary_sensor.ambr | 1825 +++++++++++++++++ .../vistapool/test_binary_sensor.py | 170 ++ 7 files changed, 2445 insertions(+) create mode 100644 homeassistant/components/vistapool/binary_sensor.py create mode 100644 tests/components/vistapool/fixtures/pool_data_all_modules.json create mode 100644 tests/components/vistapool/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/vistapool/test_binary_sensor.py diff --git a/homeassistant/components/vistapool/__init__.py b/homeassistant/components/vistapool/__init__.py index 0f21964ffa62..d9987c3c4679 100644 --- a/homeassistant/components/vistapool/__init__.py +++ b/homeassistant/components/vistapool/__init__.py @@ -17,6 +17,7 @@ from .coordinator import VistapoolDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT, Platform.NUMBER, diff --git a/homeassistant/components/vistapool/binary_sensor.py b/homeassistant/components/vistapool/binary_sensor.py new file mode 100644 index 000000000000..a7708682e8bf --- /dev/null +++ b/homeassistant/components/vistapool/binary_sensor.py @@ -0,0 +1,255 @@ +"""Vistapool Binary Sensor entities.""" + +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import VistapoolConfigEntry +from .const import ( + PATH_HASCD, + PATH_HASCL, + PATH_HASHIDRO, + PATH_HASIO, + PATH_HASPH, + PATH_HASRX, +) +from .coordinator import VistapoolDataUpdateCoordinator +from .entity import VistapoolEntity + +PARALLEL_UPDATES = 0 + +TANK_MODULE_PATHS = ( + "modules.ph.tank", + "modules.rx.tank", + "modules.cl.tank", + "modules.cd.tank", +) + + +@dataclass(frozen=True, kw_only=True) +class VistapoolBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes a Vistapool binary sensor entity.""" + + value_path: str + exists_path: str | tuple[str, ...] | None = None + + +BINARY_SENSOR_DESCRIPTIONS: tuple[VistapoolBinarySensorEntityDescription, ...] = ( + VistapoolBinarySensorEntityDescription( + key="filtration", + translation_key="filtration", + device_class=BinarySensorDeviceClass.RUNNING, + value_path="filtration.status", + ), + VistapoolBinarySensorEntityDescription( + key="backwash", + translation_key="backwash", + device_class=BinarySensorDeviceClass.RUNNING, + value_path="backwash.status", + ), + VistapoolBinarySensorEntityDescription( + key="heating", + translation_key="heating", + device_class=BinarySensorDeviceClass.RUNNING, + value_path="relays.filtration.heating.status", + ), + VistapoolBinarySensorEntityDescription( + key="hidro_flow", + translation_key="hidro_flow", + device_class=BinarySensorDeviceClass.PROBLEM, + value_path="hidro.fl1", + exists_path=PATH_HASHIDRO, + ), + VistapoolBinarySensorEntityDescription( + key="hidro_cover_reduction", + translation_key="hidro_cover_reduction", + device_class=BinarySensorDeviceClass.RUNNING, + value_path="hidro.cover", + exists_path=PATH_HASHIDRO, + ), + VistapoolBinarySensorEntityDescription( + key="hidro_fl2", + translation_key="hidro_fl2", + device_class=BinarySensorDeviceClass.PROBLEM, + value_path="hidro.fl2", + exists_path=(PATH_HASHIDRO, PATH_HASCL), + ), + VistapoolBinarySensorEntityDescription( + key="chlorine_pump", + translation_key="chlorine_pump", + device_class=BinarySensorDeviceClass.RUNNING, + value_path="modules.cl.pump_status", + exists_path=PATH_HASCL, + ), + VistapoolBinarySensorEntityDescription( + key="redox_pump", + translation_key="redox_pump", + device_class=BinarySensorDeviceClass.RUNNING, + value_path="modules.rx.pump_status", + exists_path=PATH_HASRX, + ), + VistapoolBinarySensorEntityDescription( + key="ph_pump_alarm", + translation_key="ph_pump_alarm", + device_class=BinarySensorDeviceClass.PROBLEM, + value_path="modules.ph.al3", + exists_path=PATH_HASPH, + ), + VistapoolBinarySensorEntityDescription( + key="ph_acid_pump", + translation_key="ph_acid_pump", + device_class=BinarySensorDeviceClass.RUNNING, + value_path="modules.ph.pump_high_on", + exists_path=PATH_HASPH, + ), + VistapoolBinarySensorEntityDescription( + key="ph_base_pump", + translation_key="ph_base_pump", + device_class=BinarySensorDeviceClass.RUNNING, + value_path="modules.ph.pump_low_on", + exists_path=PATH_HASPH, + ), + VistapoolBinarySensorEntityDescription( + key="conductivity_module", + translation_key="conductivity_module", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_path=PATH_HASCD, + ), + VistapoolBinarySensorEntityDescription( + key="chlorine_module", + translation_key="chlorine_module", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_path=PATH_HASCL, + ), + VistapoolBinarySensorEntityDescription( + key="redox_module", + translation_key="redox_module", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_path=PATH_HASRX, + ), + VistapoolBinarySensorEntityDescription( + key="ph_module", + translation_key="ph_module", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_path=PATH_HASPH, + ), + VistapoolBinarySensorEntityDescription( + key="io_module", + translation_key="io_module", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_path=PATH_HASIO, + ), + VistapoolBinarySensorEntityDescription( + key="hidro_module", + translation_key="hidro_module", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_path=PATH_HASHIDRO, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: VistapoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Vistapool binary sensors for every pool on the account.""" + entities: list[BinarySensorEntity] = [] + + for coordinator in entry.runtime_data.coordinators.values(): + for description in BINARY_SENSOR_DESCRIPTIONS: + if description.exists_path is not None: + required = ( + (description.exists_path,) + if isinstance(description.exists_path, str) + else description.exists_path + ) + if not all(coordinator.get_value(path) for path in required): + continue + entities.append(VistapoolBinarySensor(coordinator, description)) + + if coordinator.get_value(PATH_HASHIDRO): + is_electrolysis = coordinator.get_value("hidro.is_electrolysis") + entities.append( + VistapoolBinarySensor( + coordinator, + VistapoolBinarySensorEntityDescription( + key="electrolysis_low" if is_electrolysis else "hydrolysis_low", + translation_key=( + "electrolysis_low" if is_electrolysis else "hydrolysis_low" + ), + device_class=BinarySensorDeviceClass.PROBLEM, + value_path="hidro.low", + ), + ) + ) + + if any( + coordinator.get_value(path) + for path in (PATH_HASCD, PATH_HASCL, PATH_HASPH, PATH_HASRX) + ): + entities.append(VistapoolDosingTankBinarySensor(coordinator)) + + async_add_entities(entities) + + +class VistapoolBinarySensor(VistapoolEntity, BinarySensorEntity): + """Generic Vistapool binary sensor driven by an entity description.""" + + entity_description: VistapoolBinarySensorEntityDescription + + def __init__( + self, + coordinator: VistapoolDataUpdateCoordinator, + description: VistapoolBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = self.build_unique_id(description.key) + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + value = self.coordinator.get_value(self.entity_description.value_path) + if value is None: + return None + return value in (True, "1") + + +class VistapoolDosingTankBinarySensor(VistapoolEntity, BinarySensorEntity): + """Dosing-tank low-level sensor: on if any installed dosing module reports low.""" + + _attr_device_class = BinarySensorDeviceClass.PROBLEM + _attr_translation_key = "dosing_tank" + + def __init__(self, coordinator: VistapoolDataUpdateCoordinator) -> None: + """Initialize the dosing-tank binary sensor.""" + super().__init__(coordinator) + self._attr_unique_id = self.build_unique_id("dosing_tank") + + @property + def is_on(self) -> bool | None: + """Return true if any tank is low, or None if no tank data is available.""" + values: list[Any] = [] + for path in TANK_MODULE_PATHS: + value = self.coordinator.get_value(path) + if value is not None: + values.append(value) + if not values: + return None + return any(value in (True, "1") for value in values) diff --git a/homeassistant/components/vistapool/const.py b/homeassistant/components/vistapool/const.py index 0bba81f19939..f7e30b95aaa9 100644 --- a/homeassistant/components/vistapool/const.py +++ b/homeassistant/components/vistapool/const.py @@ -7,6 +7,7 @@ MODEL = "Vistapool" PATH_PREFIX = "main." PATH_HASCD = f"{PATH_PREFIX}hasCD" PATH_HASCL = f"{PATH_PREFIX}hasCL" +PATH_HASIO = f"{PATH_PREFIX}hasIO" PATH_HASPH = f"{PATH_PREFIX}hasPH" PATH_HASRX = f"{PATH_PREFIX}hasRX" PATH_HASUV = f"{PATH_PREFIX}hasUV" diff --git a/homeassistant/components/vistapool/strings.json b/homeassistant/components/vistapool/strings.json index 3dc5b716b426..dfd566b85057 100644 --- a/homeassistant/components/vistapool/strings.json +++ b/homeassistant/components/vistapool/strings.json @@ -39,6 +39,68 @@ } }, "entity": { + "binary_sensor": { + "backwash": { + "name": "Backwash" + }, + "chlorine_module": { + "name": "Chlorine module" + }, + "chlorine_pump": { + "name": "Chlorine pump" + }, + "conductivity_module": { + "name": "Conductivity module" + }, + "dosing_tank": { + "name": "Dosing tank" + }, + "electrolysis_low": { + "name": "Electrolysis low" + }, + "filtration": { + "name": "Filtration" + }, + "heating": { + "name": "Heating" + }, + "hidro_cover_reduction": { + "name": "Hidro cover reduction" + }, + "hidro_fl2": { + "name": "Hidro FL2" + }, + "hidro_flow": { + "name": "Hidro flow" + }, + "hidro_module": { + "name": "Hidro module" + }, + "hydrolysis_low": { + "name": "Hydrolysis low" + }, + "io_module": { + "name": "IO module" + }, + "ph_acid_pump": { + "name": "pH acid pump" + }, + "ph_base_pump": { + "name": "pH base pump" + }, + "ph_module": { + "name": "pH module" + }, + "ph_pump_alarm": { + "name": "pH pump alarm" + }, + "redox_module": { + "name": "Redox module" + }, + "redox_pump": { + "name": "Redox pump" + } + }, "button": { "led_pulse": { "name": "LED next color" diff --git a/tests/components/vistapool/fixtures/pool_data_all_modules.json b/tests/components/vistapool/fixtures/pool_data_all_modules.json new file mode 100644 index 000000000000..5ffde6318770 --- /dev/null +++ b/tests/components/vistapool/fixtures/pool_data_all_modules.json @@ -0,0 +1,131 @@ +{ + "main": { + "temperature": 25.5, + "version": 825, + "RSSI": -65, + "hasCD": 1, + "hasCL": 1, + "hasPH": 1, + "hasRX": 1, + "hasUV": 1, + "hasHidro": 1, + "hasIO": 1, + "hasLED": 1, + "localTime": 1775995380 + }, + "modules": { + "ph": { + "current": "742", + "tank": 0, + "pump_high_on": 0, + "pump_low_on": 0, + "al3": 0, + "status": { + "low_value": "650", + "high_value": "751" + } + }, + "rx": { + "current": 707, + "tank": 0, + "status": { + "value": 700 + }, + "pump_status": 0 + }, + "cl": { + "current": "120", + "tank": 0, + "pump_status": 0 + }, + "cd": { + "current": "150", + "tank": 0 + }, + "uv": { + "current": "100" + } + }, + "hidro": { + "current": 50, + "level": 100, + "fl1": 0, + "fl2": 0, + "low": 0, + "cover": 0, + "cover_enabled": 0, + "cloration_enabled": 0, + "maxAllowedValue": 220, + "is_electrolysis": true + }, + "filtration": { + "status": 0, + "mode": 1, + "manVel": 2, + "interval1": { + "from": 28800, + "to": 36000 + }, + "interval2": { + "from": 46800, + "to": 50400 + }, + "interval3": { + "from": 68400, + "to": 70200 + }, + "timerVel1": 1, + "timerVel2": 1, + "timerVel3": 0, + "intel": { + "time": "600", + "temp": 24 + } + }, + "light": { + "status": 0 + }, + "relays": { + "relay1": { + "info": { + "onoff": 0, + "status": 0 + } + }, + "relay2": { + "info": { + "onoff": 0, + "status": 0 + } + }, + "relay3": { + "info": { + "onoff": 0, + "status": 0 + } + }, + "relay4": { + "info": { + "onoff": 0, + "status": 0 + } + }, + "filtration": { + "heating": { + "status": 0 + } + } + }, + "backwash": { + "status": 0 + }, + "form": { + "lat": "50.7", + "lng": "4.4", + "city": "Waterloo", + "street": "Rue Test", + "zipcode": "1410", + "country": "BE" + }, + "present": true +} diff --git a/tests/components/vistapool/snapshots/test_binary_sensor.ambr b/tests/components/vistapool/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..47ac13087917 --- /dev/null +++ b/tests/components/vistapool/snapshots/test_binary_sensor.ambr @@ -0,0 +1,1825 @@ +# serializer version: 1 +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_backwash-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_backwash', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Backwash', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Backwash', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backwash', + 'unique_id': 'ABCDEF1234567890-backwash', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_backwash-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Backwash', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_backwash', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_chlorine_module-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_chlorine_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Chlorine module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Chlorine module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'chlorine_module', + 'unique_id': 'ABCDEF1234567890-chlorine_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_chlorine_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Chlorine module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_chlorine_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_chlorine_pump-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_chlorine_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Chlorine pump', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Chlorine pump', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'chlorine_pump', + 'unique_id': 'ABCDEF1234567890-chlorine_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_chlorine_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Chlorine pump', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_chlorine_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_conductivity_module-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_conductivity_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Conductivity module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Conductivity module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'conductivity_module', + 'unique_id': 'ABCDEF1234567890-conductivity_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_conductivity_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Conductivity module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_conductivity_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_dosing_tank-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_dosing_tank', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Dosing tank', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dosing tank', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dosing_tank', + 'unique_id': 'ABCDEF1234567890-dosing_tank', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_dosing_tank-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'My Pool Dosing tank', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_dosing_tank', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_electrolysis_low-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_electrolysis_low', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electrolysis low', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electrolysis low', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electrolysis_low', + 'unique_id': 'ABCDEF1234567890-electrolysis_low', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_electrolysis_low-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'My Pool Electrolysis low', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_electrolysis_low', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_filtration-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_filtration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filtration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filtration', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filtration', + 'unique_id': 'ABCDEF1234567890-filtration', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_filtration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Filtration', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_filtration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_heating-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_heating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Heating', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating', + 'unique_id': 'ABCDEF1234567890-heating', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_heating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Heating', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_heating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_hidro_cover_reduction-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_hidro_cover_reduction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hidro cover reduction', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hidro cover reduction', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_cover_reduction', + 'unique_id': 'ABCDEF1234567890-hidro_cover_reduction', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_hidro_cover_reduction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Hidro cover reduction', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_hidro_cover_reduction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_hidro_fl2-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_hidro_fl2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hidro FL2', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hidro FL2', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_fl2', + 'unique_id': 'ABCDEF1234567890-hidro_fl2', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_hidro_fl2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'My Pool Hidro FL2', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_hidro_fl2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_hidro_flow-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_hidro_flow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hidro flow', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hidro flow', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_flow', + 'unique_id': 'ABCDEF1234567890-hidro_flow', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_hidro_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'My Pool Hidro flow', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_hidro_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_hidro_module-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_hidro_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hidro module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hidro module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_module', + 'unique_id': 'ABCDEF1234567890-hidro_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_hidro_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Hidro module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_hidro_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_io_module-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_io_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'IO module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'IO module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'io_module', + 'unique_id': 'ABCDEF1234567890-io_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_io_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool IO module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_io_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_ph_acid_pump-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_ph_acid_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH acid pump', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH acid pump', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_acid_pump', + 'unique_id': 'ABCDEF1234567890-ph_acid_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_ph_acid_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool pH acid pump', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_ph_acid_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_ph_base_pump-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_ph_base_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH base pump', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH base pump', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_base_pump', + 'unique_id': 'ABCDEF1234567890-ph_base_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_ph_base_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool pH base pump', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_ph_base_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_ph_module-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_ph_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'pH module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_module', + 'unique_id': 'ABCDEF1234567890-ph_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_ph_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool pH module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_ph_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_ph_pump_alarm-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_ph_pump_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH pump alarm', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH pump alarm', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_pump_alarm', + 'unique_id': 'ABCDEF1234567890-ph_pump_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_ph_pump_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'My Pool pH pump alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_ph_pump_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_redox_module-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_redox_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Redox module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Redox module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'redox_module', + 'unique_id': 'ABCDEF1234567890-redox_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_redox_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Redox module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_redox_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_redox_pump-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_redox_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Redox pump', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Redox pump', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'redox_pump', + 'unique_id': 'ABCDEF1234567890-redox_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[all_modules_enabled][binary_sensor.my_pool_redox_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Redox pump', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_redox_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_backwash-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_backwash', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Backwash', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Backwash', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backwash', + 'unique_id': 'ABCDEF1234567890-backwash', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_backwash-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Backwash', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_backwash', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_chlorine_module-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_chlorine_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Chlorine module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Chlorine module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'chlorine_module', + 'unique_id': 'ABCDEF1234567890-chlorine_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_chlorine_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Chlorine module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_chlorine_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_conductivity_module-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_conductivity_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Conductivity module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Conductivity module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'conductivity_module', + 'unique_id': 'ABCDEF1234567890-conductivity_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_conductivity_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Conductivity module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_conductivity_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_dosing_tank-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_dosing_tank', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Dosing tank', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dosing tank', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dosing_tank', + 'unique_id': 'ABCDEF1234567890-dosing_tank', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_dosing_tank-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'My Pool Dosing tank', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_dosing_tank', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_electrolysis_low-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_electrolysis_low', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electrolysis low', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electrolysis low', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electrolysis_low', + 'unique_id': 'ABCDEF1234567890-electrolysis_low', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_electrolysis_low-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'My Pool Electrolysis low', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_electrolysis_low', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_filtration-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_filtration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filtration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filtration', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filtration', + 'unique_id': 'ABCDEF1234567890-filtration', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_filtration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Filtration', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_filtration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_heating-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_heating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Heating', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating', + 'unique_id': 'ABCDEF1234567890-heating', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_heating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Heating', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_heating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_hidro_cover_reduction-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_hidro_cover_reduction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hidro cover reduction', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hidro cover reduction', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_cover_reduction', + 'unique_id': 'ABCDEF1234567890-hidro_cover_reduction', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_hidro_cover_reduction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Hidro cover reduction', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_hidro_cover_reduction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_hidro_flow-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_hidro_flow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hidro flow', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hidro flow', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_flow', + 'unique_id': 'ABCDEF1234567890-hidro_flow', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_hidro_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'My Pool Hidro flow', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_hidro_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_hidro_module-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_hidro_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hidro module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hidro module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_module', + 'unique_id': 'ABCDEF1234567890-hidro_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_hidro_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Hidro module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_hidro_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_io_module-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_io_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'IO module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'IO module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'io_module', + 'unique_id': 'ABCDEF1234567890-io_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_io_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool IO module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_io_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_ph_acid_pump-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_ph_acid_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH acid pump', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH acid pump', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_acid_pump', + 'unique_id': 'ABCDEF1234567890-ph_acid_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_ph_acid_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool pH acid pump', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_ph_acid_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_ph_base_pump-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_ph_base_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH base pump', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH base pump', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_base_pump', + 'unique_id': 'ABCDEF1234567890-ph_base_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_ph_base_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool pH base pump', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_ph_base_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_ph_module-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_ph_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'pH module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_module', + 'unique_id': 'ABCDEF1234567890-ph_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_ph_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool pH module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_ph_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_ph_pump_alarm-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_ph_pump_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH pump alarm', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH pump alarm', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_pump_alarm', + 'unique_id': 'ABCDEF1234567890-ph_pump_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_ph_pump_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'My Pool pH pump alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_ph_pump_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_redox_module-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_pool_redox_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Redox module', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Redox module', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'redox_module', + 'unique_id': 'ABCDEF1234567890-redox_module', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_redox_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Redox module', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_redox_module', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_redox_pump-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_pool_redox_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Redox pump', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Redox pump', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'redox_pump', + 'unique_id': 'ABCDEF1234567890-redox_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[default][binary_sensor.my_pool_redox_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'My Pool Redox pump', + }), + 'context': , + 'entity_id': 'binary_sensor.my_pool_redox_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/vistapool/test_binary_sensor.py b/tests/components/vistapool/test_binary_sensor.py new file mode 100644 index 000000000000..fc84e3012437 --- /dev/null +++ b/tests/components/vistapool/test_binary_sensor.py @@ -0,0 +1,170 @@ +"""Tests for the Vistapool binary_sensor platform.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.vistapool.const import DOMAIN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import ( + MockConfigEntry, + async_load_json_object_fixture, + snapshot_platform, +) + + +@pytest.fixture(autouse=True) +def _only_binary_sensor_platform() -> Generator[None]: + """Restrict integration setup to the binary_sensor platform for these tests.""" + with patch( + "homeassistant.components.vistapool.PLATFORMS", [Platform.BINARY_SENSOR] + ): + yield + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "fixture_name", + [ + pytest.param("pool_data.json", id="default"), + pytest.param("pool_data_all_modules.json", id="all_modules_enabled"), + ], +) +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + fixture_name: str, +) -> None: + """Test binary sensor entities for fixtures covering modules off and on.""" + mock_vistapool_client.fetch_pool_data.return_value = ( + await async_load_json_object_fixture(hass, fixture_name, DOMAIN) + ) + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_binary_sensors_hydrolysis_branch( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test the hydrolysis (non-electrolysis) branch creates the right entity.""" + mock_vistapool_client.fetch_pool_data.return_value = { + "main": {"hasHidro": 1, "version": 1}, + "hidro": {"is_electrolysis": False, "low": 1}, + } + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.my_pool_hydrolysis_low").state == STATE_ON + assert hass.states.get("binary_sensor.my_pool_electrolysis_low") is None + + +async def test_binary_sensors_dosing_tank_low( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test the dosing-tank sensor reports `on` when any installed tank is low.""" + mock_vistapool_client.fetch_pool_data.return_value = { + "main": {"hasPH": 1, "version": 1}, + "modules": {"ph": {"tank": 1}}, + } + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.my_pool_dosing_tank").state == STATE_ON + + +async def test_binary_sensors_dosing_tank_unknown_when_no_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test the dosing-tank sensor reports unknown when no tank values are available.""" + mock_vistapool_client.fetch_pool_data.return_value = { + "main": {"hasPH": 1, "version": 1}, + } + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.my_pool_dosing_tank").state == STATE_UNKNOWN + + +async def test_binary_sensors_string_values( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test the Vistapool API's numeric-as-string values are coerced correctly.""" + mock_vistapool_client.fetch_pool_data.return_value = { + "main": {"version": 1}, + "filtration": {"status": "1"}, + "backwash": {"status": "0"}, + } + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.my_pool_filtration").state == STATE_ON + assert hass.states.get("binary_sensor.my_pool_backwash").state == STATE_OFF + + +async def test_binary_sensors_fl2_requires_hidro( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test hidro_fl2 is not created when hasCL is set but hasHidro is not.""" + mock_vistapool_client.fetch_pool_data.return_value = { + "main": {"hasCL": 1, "hasHidro": 0, "version": 1}, + "modules": {"cl": {"pump_status": 0, "tank": 0}}, + } + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.my_pool_hidro_fl2") is None + assert hass.states.get("binary_sensor.my_pool_chlorine_pump") is not None + + +async def test_binary_sensors_multi_pool( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + mock_pool_data: dict[str, Any], +) -> None: + """Test setup creates binary sensors for every pool on the account.""" + mock_vistapool_client.get_pools.return_value = { + "pool_a": "Pool A", + "pool_b": "Pool B", + } + mock_vistapool_client.fetch_pool_data.return_value = mock_pool_data + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.pool_a_filtration").state == STATE_ON + assert hass.states.get("binary_sensor.pool_b_filtration").state == STATE_ON From e56c221eb1b433cde727ed3aea9a65210c8a2a03 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 11 Jun 2026 14:46:06 +0200 Subject: [PATCH 111/404] Add tests documenting nested event firing behavior (#173491) --- tests/test_core.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index 591147f30c86..7ab86857b04d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1333,6 +1333,60 @@ async def test_eventbus_listen_once_run_immediately_coro(hass: HomeAssistant) -> assert len(calls) == 1 +async def test_eventbus_nested_fire_dispatch_order(hass: HomeAssistant) -> None: + """Test dispatch order when a listener fires an event synchronously. + + Event dispatch is reentrant: an event fired from within a synchronous + listener is dispatched immediately, nested inside the dispatch of the + outer event. + + The implementation of event listeners is such that listeners are called + in the order they were registered + + As a result, the order in which a listener observes the two + events depends on its registration position relative to the listener + which fires the nested event: listeners registered before it observe + fire order, listeners registered after it observe the nested event + first. + + This test documents the current behavior rather than guarantees it: a + non-reentrant (queued) dispatch would make all listeners observe fire + order. + """ + observed_before: list[str] = [] + observed_after: list[str] = [] + + @ha.callback + def observer_before(event: ha.Event) -> None: + observed_before.append(event.event_type) + + @ha.callback + def fire_nested(event: ha.Event) -> None: + hass.bus.async_fire("test_nested") + + @ha.callback + def observer_after(event: ha.Event) -> None: + observed_after.append(event.event_type) + + unsubs = [ + hass.bus.async_listen("test_outer", observer_before), + hass.bus.async_listen("test_nested", observer_before), + hass.bus.async_listen("test_outer", fire_nested), + hass.bus.async_listen("test_outer", observer_after), + hass.bus.async_listen("test_nested", observer_after), + ] + + hass.bus.async_fire("test_outer") + + # Registered before the nesting listener: observes fire order. + assert observed_before == ["test_outer", "test_nested"] + # Registered after the nesting listener: observes inverted order. + assert observed_after == ["test_nested", "test_outer"] + + for unsub in unsubs: + unsub() + + async def test_eventbus_unsubscribe_listener(hass: HomeAssistant) -> None: """Test unsubscribe listener from returned function.""" calls = [] From 851facd82655040cd318bdb7e98b6a866513b66f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 11 Jun 2026 14:48:02 +0200 Subject: [PATCH 112/404] Reolink UID in the config entry (#173505) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/reolink/__init__.py | 26 +++++++++- .../components/reolink/config_flow.py | 2 + homeassistant/components/reolink/const.py | 1 + homeassistant/components/reolink/host.py | 3 ++ homeassistant/components/reolink/strings.json | 3 ++ tests/components/reolink/conftest.py | 4 ++ tests/components/reolink/test_config_flow.py | 18 +++++++ tests/components/reolink/test_init.py | 51 +++++++++++++++++++ 8 files changed, 107 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index f58eeefc318f..64cc0ab486f2 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -8,11 +8,16 @@ from time import time from typing import Any from reolink_aio.api import DUAL_LENS_DUAL_MOTION_MODELS, RETRY_ATTEMPTS +from reolink_aio.const import UNKNOWN from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -29,6 +34,7 @@ from .const import ( CONF_BC_PORT, CONF_FIRMWARE_CHECK_TIME, CONF_SUPPORTS_PRIVACY_MODE, + CONF_UID, CONF_USE_HTTPS, DOMAIN, ) @@ -95,6 +101,22 @@ async def async_setup_entry( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop) ) + # do not allow changes to the UID + if ( + config_entry.data.get(CONF_UID, host.api.uid) != host.api.uid + and config_entry.data.get(CONF_UID) != UNKNOWN + ): + await host.stop() + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="uid_mismatch", + translation_placeholders={ + "name": host.api.nvr_name, + "conf_uid": config_entry.data.get(CONF_UID, ""), + "uid": host.api.uid, + }, + ) + # update the config info if needed for the next time if ( host.api.port != config_entry.data[CONF_PORT] @@ -105,6 +127,7 @@ async def async_setup_entry( or host.api.baichuan_only != config_entry.data.get(CONF_BC_ONLY) or host.api.baichuan.connection_type.value != config_entry.data.get(CONF_BC_CONNECT) + or host.api.uid != config_entry.data.get(CONF_UID) ): if host.api.port != config_entry.data[CONF_PORT]: _LOGGER.warning( @@ -130,6 +153,7 @@ async def async_setup_entry( CONF_BC_PORT: host.api.baichuan.port, CONF_BC_ONLY: host.api.baichuan_only, CONF_BC_CONNECT: host.api.baichuan.connection_type.value, + CONF_UID: host.api.uid, CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"), } hass.config_entries.async_update_entry(config_entry, data=data) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 357b255eb916..f91a102fe429 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -41,6 +41,7 @@ from .const import ( CONF_BC_ONLY, CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, + CONF_UID, CONF_USE_HTTPS, DOMAIN, ) @@ -312,6 +313,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): user_input[CONF_BC_PORT] = host.api.baichuan.port user_input[CONF_BC_ONLY] = host.api.baichuan_only user_input[CONF_BC_CONNECT] = host.api.baichuan.connection_type.value + user_input[CONF_UID] = host.api.uid user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported( None, "privacy_mode" ) diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index f76d9c4ef18c..e019822faf75 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -10,6 +10,7 @@ CONF_BC_ONLY = "baichuan_only" CONF_BC_CONNECT = "baichuan_connection" CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported" CONF_FIRMWARE_CHECK_TIME = "firmware_check_time" +CONF_UID = "uid" # Conserve battery by not waking the battery cameras each minute during normal update # Most props are cached in the Home Hub and updated, but some are skipped diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index ab80e1396a2d..f08868377b05 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -11,6 +11,7 @@ import aiohttp from aiohttp.web import Request from reolink_aio.api import ALLOWED_SPECIAL_CHARS, Host from reolink_aio.baichuan import DEFAULT_BC_PORT +from reolink_aio.const import UNKNOWN from reolink_aio.enums import ConnectionEnum, SubType from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError @@ -40,6 +41,7 @@ from .const import ( CONF_BC_ONLY, CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, + CONF_UID, CONF_USE_HTTPS, DOMAIN, ) @@ -105,6 +107,7 @@ class ReolinkHost: bc_port=config.get(CONF_BC_PORT, DEFAULT_BC_PORT), bc_connection=bc_connection, bc_only=config.get(CONF_BC_ONLY, False), + uid=config.get(CONF_UID, UNKNOWN), ) self.last_wake: defaultdict[int, float] = defaultdict(float) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 8731bfcdcf07..269fa81f827c 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -918,6 +918,9 @@ "timeout": { "message": "Timeout waiting on a response: {err}" }, + "uid_mismatch": { + "message": "UID {uid} of Reolink camera \"{name}\" did not match the stored configuration UID {conf_uid}, please check the connection details" + }, "unexpected": { "message": "Unexpected Reolink error: {err}" }, diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 37a2e98dcf1f..07d4abb64fdc 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -10,9 +10,11 @@ from reolink_aio.exceptions import ReolinkError from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.const import ( + CONF_BC_CONNECT, CONF_BC_ONLY, CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, + CONF_UID, CONF_USE_HTTPS, DOMAIN, ) @@ -244,6 +246,8 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, CONF_BC_PORT: TEST_BC_PORT, CONF_BC_ONLY: False, + CONF_BC_CONNECT: TEST_BC_CON, + CONF_UID: TEST_UID, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index c643fb20e34f..17a79187f319 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -23,6 +23,7 @@ from homeassistant.components.reolink.const import ( CONF_BC_ONLY, CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, + CONF_UID, CONF_USE_HTTPS, DOMAIN, ) @@ -54,6 +55,7 @@ from .conftest import ( TEST_PASSWORD2, TEST_PORT, TEST_PRIVACY, + TEST_UID, TEST_USE_HTTPS, TEST_USERNAME, TEST_USERNAME2, @@ -96,6 +98,7 @@ async def test_config_flow_manual_success(hass: HomeAssistant) -> None: CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -152,6 +155,7 @@ async def test_config_flow_privacy_success( CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -195,6 +199,7 @@ async def test_config_flow_baichuan_only( CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: True, + CONF_UID: TEST_UID, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -358,6 +363,7 @@ async def test_config_flow_errors(hass: HomeAssistant, reolink_host: MagicMock) CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -379,6 +385,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, }, options={ CONF_PROTOCOL: "rtsp", @@ -421,6 +428,7 @@ async def test_reauth(hass: HomeAssistant) -> None: CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -470,6 +478,7 @@ async def test_reauth_abort_unique_id_mismatch( CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -541,6 +550,7 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -566,6 +576,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -609,6 +620,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( bc_port=TEST_BC_PORT, bc_connection=ConnectionEnum(TEST_BC_CON), bc_only=False, + uid=TEST_UID, ) assert expected_call in reolink_host_class.call_args_list @@ -692,6 +704,7 @@ async def test_dhcp_ip_update( CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -736,6 +749,7 @@ async def test_dhcp_ip_update( bc_port=TEST_BC_PORT, bc_connection=ConnectionEnum(TEST_BC_CON), bc_only=False, + uid=TEST_UID, ) assert expected_call in reolink_host_class.call_args_list @@ -770,6 +784,7 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -804,6 +819,7 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( bc_port=TEST_BC_PORT, bc_connection=ConnectionEnum(TEST_BC_CON), bc_only=False, + uid=TEST_UID, ) assert expected_call in reolink_host_class.call_args_list @@ -834,6 +850,7 @@ async def test_reconfig(hass: HomeAssistant) -> None: CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -884,6 +901,7 @@ async def test_reconfig_abort_unique_id_mismatch( CONF_BC_PORT: TEST_BC_PORT, CONF_BC_CONNECT: TEST_BC_CON, CONF_BC_ONLY: False, + CONF_UID: TEST_UID, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index b3674e226690..d8e8f8cde547 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -49,10 +49,13 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format from homeassistant.setup import async_setup_component from .conftest import ( + CONF_BC_CONNECT, CONF_BC_ONLY, CONF_SUPPORTS_PRIVACY_MODE, + CONF_UID, CONF_USE_HTTPS, DEFAULT_PROTOCOL, + TEST_BC_CON, TEST_BC_PORT, TEST_CAM_MODEL, TEST_CAM_NAME, @@ -969,6 +972,54 @@ async def test_baichuan_port_changed( assert config_entry.data[CONF_BC_PORT] == 8901 +async def test_uid_changed( + hass: HomeAssistant, + reolink_host: MagicMock, +) -> None: + """Test the addition of the UID to the config entry when not initially present.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=format_mac(TEST_MAC), + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_CONNECT: TEST_BC_CON, + CONF_BC_ONLY: False, + }, + options={ + CONF_PROTOCOL: DEFAULT_PROTOCOL, + }, + title=TEST_NVR_NAME, + ) + config_entry.add_to_hass(hass) + + assert CONF_UID not in config_entry.data + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.data[CONF_UID] == TEST_UID + + +async def test_uid_changed_error( + hass: HomeAssistant, + reolink_host: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test a change of the UID is not accepted and results in an error during init.""" + assert config_entry.data[CONF_UID] == TEST_UID + reolink_host.uid = "SOME2OTHER89UID4" + + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.data[CONF_UID] == TEST_UID + + async def test_privacy_mode_on( hass: HomeAssistant, freezer: FrozenDateTimeFactory, From 0dec701acd0e707c6232e4517bcda0cc96e87fa2 Mon Sep 17 00:00:00 2001 From: bkobus-bbx Date: Thu, 11 Jun 2026 14:48:48 +0200 Subject: [PATCH 113/404] Add support for CO2Sensor to Blebox integration (#173507) --- homeassistant/components/blebox/const.py | 10 +++ homeassistant/components/blebox/icons.json | 3 + homeassistant/components/blebox/sensor.py | 16 +++- homeassistant/components/blebox/strings.json | 12 +++ tests/components/blebox/test_sensor.py | 77 +++++++++++++++++++- 5 files changed, 116 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/blebox/const.py b/homeassistant/components/blebox/const.py index ef264becbffb..102c74e81581 100644 --- a/homeassistant/components/blebox/const.py +++ b/homeassistant/components/blebox/const.py @@ -24,3 +24,13 @@ OPEN_STATUS: dict[int, str] = { LIGHT_MAX_KELVINS = 6500 # 154 Mireds LIGHT_MIN_KELVINS = 2700 # 370 Mireds + +CO2_LEVEL: dict[int, str] = { + 0: "excellent", + 1: "good", + 2: "acceptable", + 3: "medium", + 4: "poor", + 5: "unhealthy", + 6: "hazardous", +} diff --git a/homeassistant/components/blebox/icons.json b/homeassistant/components/blebox/icons.json index 3c0f5123dbc0..5e4dcb9081e8 100644 --- a/homeassistant/components/blebox/icons.json +++ b/homeassistant/components/blebox/icons.json @@ -18,6 +18,9 @@ } }, "sensor": { + "co2_level": { + "default": "mdi:molecule-co2" + }, "open_status": { "default": "mdi:window-open" }, diff --git a/homeassistant/components/blebox/sensor.py b/homeassistant/components/blebox/sensor.py index d41fb6ff07e9..8e1e561cfb07 100644 --- a/homeassistant/components/blebox/sensor.py +++ b/homeassistant/components/blebox/sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, UnitOfApparentPower, @@ -31,7 +32,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import BleBoxConfigEntry -from .const import OPEN_STATUS +from .const import CO2_LEVEL, OPEN_STATUS from .coordinator import BleBoxCoordinator from .entity import BleBoxEntity @@ -149,6 +150,19 @@ SENSOR_TYPES: tuple[BleBoxSensorEntityDescription, ...] = ( options=list(OPEN_STATUS.values()), value_fn=lambda v: OPEN_STATUS.get(int(v)) if v is not None else None, ), + BleBoxSensorEntityDescription( + key="co2", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ), + BleBoxSensorEntityDescription( + key="co2Definition", + translation_key="co2_level", + device_class=SensorDeviceClass.ENUM, + options=list(CO2_LEVEL.values()), + value_fn=lambda v: CO2_LEVEL.get(int(v)) if v is not None else None, + ), ) diff --git a/homeassistant/components/blebox/strings.json b/homeassistant/components/blebox/strings.json index cbe6be653fe4..424016741210 100644 --- a/homeassistant/components/blebox/strings.json +++ b/homeassistant/components/blebox/strings.json @@ -37,6 +37,18 @@ }, "entity": { "sensor": { + "co2_level": { + "name": "Carbon dioxide level", + "state": { + "acceptable": "Acceptable", + "excellent": "Excellent", + "good": "Good", + "hazardous": "Hazardous", + "medium": "Medium", + "poor": "Poor", + "unhealthy": "Unhealthy" + } + }, "open_status": { "state": { "ajar": "Ajar", diff --git a/tests/components/blebox/test_sensor.py b/tests/components/blebox/test_sensor.py index 0bce8658e6fe..3ada992f6de0 100644 --- a/tests/components/blebox/test_sensor.py +++ b/tests/components/blebox/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, PropertyMock import blebox_uniapi import pytest -from homeassistant.components.blebox.const import OPEN_STATUS +from homeassistant.components.blebox.const import CO2_LEVEL, OPEN_STATUS from homeassistant.components.sensor import ATTR_OPTIONS, SensorDeviceClass from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -231,3 +231,78 @@ async def test_open_status_sensor_none_value( state = hass.states.get(entity_id) assert state.state == STATE_UNKNOWN + + +@pytest.fixture(name="co2_definition_sensor") +def co2_definition_sensor_fixture(): + """Return a default co2Definition sensor mock.""" + feature = mock_feature( + "sensors", + blebox_uniapi.sensor.GenericSensor, + unique_id="BleBox-co2Sensor-1afe34db9437-0.co2Definition", + full_name="co2Sensor-0.co2Definition", + device_class="co2Definition", + native_value=None, + ) + product = feature.product + type(product).name = PropertyMock(return_value="My CO2 sensor") + type(product).model = PropertyMock(return_value="co2Sensor") + return (feature, "sensor.my_co2_sensor_co2sensor_0_co2definition") + + +async def test_co2_definition_sensor_init( + co2_definition_sensor, hass: HomeAssistant +) -> None: + """Test co2Definition sensor initial state is unknown.""" + _, entity_id = co2_definition_sensor + await async_setup_entity(hass, entity_id) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM + assert state.attributes[ATTR_OPTIONS] == list(CO2_LEVEL.values()) + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("raw_value", "expected_state"), + [ + pytest.param(0, "excellent", id="0_excellent"), + pytest.param(1, "good", id="1_good"), + pytest.param(2, "acceptable", id="2_acceptable"), + pytest.param(3, "medium", id="3_medium"), + pytest.param(4, "poor", id="4_poor"), + pytest.param(5, "unhealthy", id="5_unhealthy"), + pytest.param(6, "hazardous", id="6_hazardous"), + ], +) +async def test_co2_definition_sensor_value_mapping( + co2_definition_sensor, + hass: HomeAssistant, + raw_value: int, + expected_state: str, +) -> None: + """Test that each raw co2Definition value maps to the correct string state.""" + feature_mock, entity_id = co2_definition_sensor + + feature_mock.native_value = raw_value + await async_setup_entity(hass, entity_id) + + state = hass.states.get(entity_id) + assert state.state == expected_state + assert state.state in CO2_LEVEL.values() + + +async def test_co2_definition_sensor_none_value( + co2_definition_sensor, hass: HomeAssistant +) -> None: + """Test that a None native_value yields an unknown state.""" + feature_mock, entity_id = co2_definition_sensor + + def set_none(): + feature_mock.native_value = None + + feature_mock.async_update = AsyncMock(side_effect=set_none) + await async_setup_entity(hass, entity_id) + + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN From 1e18b77c67b700a86117470f71a50fc30fa22602 Mon Sep 17 00:00:00 2001 From: bkobus-bbx Date: Thu, 11 Jun 2026 14:52:45 +0200 Subject: [PATCH 114/404] Expose SET_TILT_POSITION only for calibrated tilt shutters (#173501) --- homeassistant/components/blebox/cover.py | 6 +++--- tests/components/blebox/test_cover.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/blebox/cover.py b/homeassistant/components/blebox/cover.py index 453030e09789..0488607963ec 100644 --- a/homeassistant/components/blebox/cover.py +++ b/homeassistant/components/blebox/cover.py @@ -90,10 +90,10 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity): if feature.has_tilt: self._attr_supported_features |= ( - CoverEntityFeature.SET_TILT_POSITION - | CoverEntityFeature.OPEN_TILT - | CoverEntityFeature.CLOSE_TILT + CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT ) + if feature.is_calibrated: + self._attr_supported_features |= CoverEntityFeature.SET_TILT_POSITION if feature.tilt_only: self._attr_supported_features &= ~( diff --git a/tests/components/blebox/test_cover.py b/tests/components/blebox/test_cover.py index 76cc1394cdc9..247fb7419f9b 100644 --- a/tests/components/blebox/test_cover.py +++ b/tests/components/blebox/test_cover.py @@ -54,6 +54,7 @@ def shutterbox_fixture(): state=None, has_stop=True, has_tilt=True, + is_calibrated=True, is_slider=True, is_position_inverted=True, cover_type=None, @@ -452,6 +453,21 @@ async def test_tilt_with_position_supported_features( assert supported_features & CoverEntityFeature.SET_TILT_POSITION +async def test_tilt_not_calibrated_no_set_tilt_position( + shutterbox, hass: HomeAssistant +) -> None: + """Test that SET_TILT_POSITION is absent when tilt is present but not calibrated.""" + feature_mock, entity_id = shutterbox + feature_mock.is_calibrated = False + + await async_setup_entity(hass, entity_id) + + supported_features = hass.states.get(entity_id).attributes[ATTR_SUPPORTED_FEATURES] + assert supported_features & CoverEntityFeature.OPEN_TILT + assert supported_features & CoverEntityFeature.CLOSE_TILT + assert not supported_features & CoverEntityFeature.SET_TILT_POSITION + + @pytest.mark.parametrize("feature", ALL_COVER_FIXTURES, indirect=["feature"]) async def test_update_failure( feature, From f5f80e7080818aaedb918e510cdd3904dfe612cf Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 11 Jun 2026 14:56:39 +0200 Subject: [PATCH 115/404] Add Reolink webhook push diagnostics (#173499) --- homeassistant/components/reolink/host.py | 2 ++ tests/components/reolink/conftest.py | 1 + tests/components/reolink/test_host.py | 7 +++++++ 3 files changed, 10 insertions(+) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index f08868377b05..865166aeac73 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -938,6 +938,8 @@ class ReolinkHost: def event_connection(self) -> str: """Type of connection to receive events.""" if self._api.baichuan.events_active: + if self._api.baichuan.webhook_subscribed: + return "Webhook push" return "TCP push" if self._webhook_reachable: return "ONVIF push" diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 07d4abb64fdc..25677b8591e5 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -170,6 +170,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.baichuan.port = TEST_BC_PORT host_mock.baichuan.connection_type = ConnectionEnum(TEST_BC_CON) host_mock.baichuan.events_active = False + host_mock.baichuan.webhook_subscribed = False host_mock.baichuan.login_sucess = True host_mock.baichuan.subscribe_events = AsyncMock() host_mock.baichuan.unsubscribe_events = AsyncMock() diff --git a/tests/components/reolink/test_host.py b/tests/components/reolink/test_host.py index 475dc1655587..012552a44c1b 100644 --- a/tests/components/reolink/test_host.py +++ b/tests/components/reolink/test_host.py @@ -553,5 +553,12 @@ async def test_diagnostics_event_connection( # set TCP push as active reolink_host.baichuan.events_active = True + reolink_host.baichuan.webhook_subscribed = False diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert diag["event connection"] == "TCP push" + + # set Webhook push as active + reolink_host.baichuan.events_active = True + reolink_host.baichuan.webhook_subscribed = True + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag["event connection"] == "Webhook push" From 8fed48d8ace84e0e8cb21c3c41dc4209df96e80a Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Thu, 11 Jun 2026 14:57:43 +0200 Subject: [PATCH 116/404] Add sensor platform to openSenseMap (#172765) --- .../components/opensensemap/__init__.py | 2 +- .../components/opensensemap/air_quality.py | 4 +- .../components/opensensemap/coordinator.py | 84 ++- .../components/opensensemap/sensor.py | 156 ++++++ tests/components/opensensemap/conftest.py | 9 + .../opensensemap/fixtures/station.json | 90 +++ .../opensensemap/snapshots/test_sensor.ambr | 517 ++++++++++++++++++ tests/components/opensensemap/test_sensor.py | 152 +++++ 8 files changed, 1007 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/opensensemap/sensor.py create mode 100644 tests/components/opensensemap/snapshots/test_sensor.ambr create mode 100644 tests/components/opensensemap/test_sensor.py diff --git a/homeassistant/components/opensensemap/__init__.py b/homeassistant/components/opensensemap/__init__.py index 85db06130080..53e6e0ee0c91 100644 --- a/homeassistant/components/opensensemap/__init__.py +++ b/homeassistant/components/opensensemap/__init__.py @@ -9,7 +9,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_STATION_ID from .coordinator import OpenSenseMapConfigEntry, OpenSenseMapCoordinator -PLATFORMS: list[Platform] = [Platform.AIR_QUALITY] +PLATFORMS: list[Platform] = [Platform.AIR_QUALITY, Platform.SENSOR] async def async_setup_entry( diff --git a/homeassistant/components/opensensemap/air_quality.py b/homeassistant/components/opensensemap/air_quality.py index ce3719ebbb43..45f5a4f015c7 100644 --- a/homeassistant/components/opensensemap/air_quality.py +++ b/homeassistant/components/opensensemap/air_quality.py @@ -117,9 +117,9 @@ class OpenSenseMapQuality(CoordinatorEntity[OpenSenseMapCoordinator], AirQuality @property def particulate_matter_2_5(self) -> float | None: """Return the particulate matter 2.5 level.""" - return self.coordinator.data.pm2_5 + return self.coordinator.data.pm2_5.value @property def particulate_matter_10(self) -> float | None: """Return the particulate matter 10 level.""" - return self.coordinator.data.pm10 + return self.coordinator.data.pm10.value diff --git a/homeassistant/components/opensensemap/coordinator.py b/homeassistant/components/opensensemap/coordinator.py index fd94363a3f85..a4a4c8a76c14 100644 --- a/homeassistant/components/opensensemap/coordinator.py +++ b/homeassistant/components/opensensemap/coordinator.py @@ -2,11 +2,13 @@ from dataclasses import dataclass from datetime import timedelta +from typing import NamedTuple -from opensensemap_api import OpenSenseMap +from opensensemap_api import _TITLES, OpenSenseMap from opensensemap_api.exceptions import OpenSenseMapError from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -14,13 +16,68 @@ from .const import DOMAIN, LOGGER SCAN_INTERVAL = timedelta(minutes=10) +# Stations report the same phenomenon in different units, but the library +# exposes only values. These map a station's reported unit (normalized to +# lowercase) to the matching Home Assistant unit so values convert correctly. +TEMPERATURE_UNITS: dict[str, str] = { + "°c": UnitOfTemperature.CELSIUS, + "c": UnitOfTemperature.CELSIUS, + "°f": UnitOfTemperature.FAHRENHEIT, + "f": UnitOfTemperature.FAHRENHEIT, +} +WIND_SPEED_UNITS: dict[str, str] = { + "m/s": UnitOfSpeed.METERS_PER_SECOND, + "km/h": UnitOfSpeed.KILOMETERS_PER_HOUR, + "mph": UnitOfSpeed.MILES_PER_HOUR, +} +PRESSURE_UNITS: dict[str, str] = { + "hpa": UnitOfPressure.HPA, + "pa": UnitOfPressure.PA, + "pascal": UnitOfPressure.PA, + "mbar": UnitOfPressure.MBAR, + "kpa": UnitOfPressure.KPA, +} + + +class Measurement(NamedTuple): + """A station measurement paired with its detected unit, if any.""" + + value: float | None + unit: str | None = None + @dataclass(slots=True, frozen=True) class OpenSenseMapStationData: """Immutable measurements for an openSenseMap station.""" - pm2_5: float | None - pm10: float | None + pm2_5: Measurement + pm10: Measurement + pm1_0: Measurement + temperature: Measurement + humidity: Measurement + air_pressure: Measurement + illuminance: Measurement + wind_speed: Measurement + wind_direction: Measurement + + +def _detect_unit( + api: OpenSenseMap, title_key: str, unit_map: dict[str, str] +) -> str | None: + """Return the Home Assistant unit for a phenomenon reported by the station.""" + + # The library resolves a measurement by matching localized sensor titles + # (opensensemap_api._TITLES) and returns the first matching sensor that has a + # value. Mirror that approach to find the matching unit. + for title in (*_TITLES.get(title_key, ()), title_key): + for sensor in api.data.get("sensors", []): + measurement = sensor.get("lastMeasurement") or {} + if ( + sensor.get("title", "").casefold() == title.casefold() + and measurement.get("value") is not None + ): + return unit_map.get((sensor.get("unit") or "").strip().casefold()) + return None type OpenSenseMapConfigEntry = ConfigEntry[OpenSenseMapCoordinator] @@ -55,4 +112,23 @@ class OpenSenseMapCoordinator(DataUpdateCoordinator[OpenSenseMapStationData]): raise UpdateFailed( f"Unable to fetch data from openSenseMap: {err}" ) from err - return OpenSenseMapStationData(pm2_5=self.api.pm2_5, pm10=self.api.pm10) + return OpenSenseMapStationData( + pm2_5=Measurement(self.api.pm2_5), + pm10=Measurement(self.api.pm10), + pm1_0=Measurement(self.api.pm1_0), + temperature=Measurement( + self.api.temperature, + _detect_unit(self.api, "Temperature", TEMPERATURE_UNITS), + ), + humidity=Measurement(self.api.humidity), + air_pressure=Measurement( + self.api.air_pressure, + _detect_unit(self.api, "Air Pressure", PRESSURE_UNITS), + ), + illuminance=Measurement(self.api.illuminance), + wind_speed=Measurement( + self.api.wind_speed, + _detect_unit(self.api, "Wind Speed", WIND_SPEED_UNITS), + ), + wind_direction=Measurement(self.api.wind_direction), + ) diff --git a/homeassistant/components/opensensemap/sensor.py b/homeassistant/components/opensensemap/sensor.py new file mode 100644 index 000000000000..b93e4c8f9b4f --- /dev/null +++ b/homeassistant/components/opensensemap/sensor.py @@ -0,0 +1,156 @@ +"""Support for openSenseMap sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + DEGREE, + LIGHT_LUX, + PERCENTAGE, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_STATION_ID, DOMAIN, INTEGRATION_TITLE +from .coordinator import ( + Measurement, + OpenSenseMapConfigEntry, + OpenSenseMapCoordinator, + OpenSenseMapStationData, +) + + +@dataclass(frozen=True, kw_only=True) +class OpenSenseMapSensorEntityDescription(SensorEntityDescription): + """Describes openSenseMap sensor entities.""" + + value_fn: Callable[[OpenSenseMapStationData], Measurement] + + +SENSOR_DESCRIPTIONS: tuple[OpenSenseMapSensorEntityDescription, ...] = ( + OpenSenseMapSensorEntityDescription( + key="pm2_5", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.pm2_5, + ), + OpenSenseMapSensorEntityDescription( + key="pm10", + device_class=SensorDeviceClass.PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.pm10, + ), + OpenSenseMapSensorEntityDescription( + key="pm1_0", + device_class=SensorDeviceClass.PM1, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.pm1_0, + ), + OpenSenseMapSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.temperature, + ), + OpenSenseMapSensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.humidity, + ), + OpenSenseMapSensorEntityDescription( + key="air_pressure", + device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, + native_unit_of_measurement=UnitOfPressure.HPA, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.air_pressure, + ), + OpenSenseMapSensorEntityDescription( + key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.illuminance, + ), + OpenSenseMapSensorEntityDescription( + key="wind_speed", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.wind_speed, + ), + OpenSenseMapSensorEntityDescription( + key="wind_direction", + device_class=SensorDeviceClass.WIND_DIRECTION, + native_unit_of_measurement=DEGREE, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + value_fn=lambda data: data.wind_direction, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OpenSenseMapConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up openSenseMap sensors from a config entry.""" + coordinator = entry.runtime_data + + entities: list[OpenSenseMapSensor] = [] + for description in SENSOR_DESCRIPTIONS: + measurement = description.value_fn(coordinator.data) + if measurement.value is None: + continue + native_unit = measurement.unit or description.native_unit_of_measurement + entities.append(OpenSenseMapSensor(coordinator, description, native_unit)) + async_add_entities(entities) + + +class OpenSenseMapSensor(CoordinatorEntity[OpenSenseMapCoordinator], SensorEntity): + """Sensor entity representing a single measurement from an openSenseMap station.""" + + _attr_attribution = "Data provided by openSenseMap" + _attr_has_entity_name = True + entity_description: OpenSenseMapSensorEntityDescription + + def __init__( + self, + coordinator: OpenSenseMapCoordinator, + description: OpenSenseMapSensorEntityDescription, + native_unit: str | None, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_native_unit_of_measurement = native_unit + station_id = coordinator.config_entry.data[CONF_STATION_ID] + self._attr_unique_id = f"{station_id}_{description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, station_id)}, + manufacturer=INTEGRATION_TITLE, + configuration_url=f"https://opensensemap.org/explore/{station_id}", + ) + + @property + def native_value(self) -> float | str | None: + """Return the latest value reported by the station.""" + return self.entity_description.value_fn(self.coordinator.data).value diff --git a/tests/components/opensensemap/conftest.py b/tests/components/opensensemap/conftest.py index f36bee5f4c40..e90915653416 100644 --- a/tests/components/opensensemap/conftest.py +++ b/tests/components/opensensemap/conftest.py @@ -52,6 +52,15 @@ async def mock_opensensemap_api( } instance.pm2_5 = sensor_values.get("PM2.5") instance.pm10 = sensor_values.get("PM10") + instance.pm1_0 = sensor_values.get("PM1.0") + instance.temperature = sensor_values.get("Temperature") + instance.humidity = sensor_values.get("Humidity") + instance.air_pressure = sensor_values.get("Air Pressure") + instance.illuminance = sensor_values.get("Illuminance") + instance.uv = sensor_values.get("UV") + instance.wind_speed = sensor_values.get("Wind Speed") + instance.wind_direction = sensor_values.get("Wind Direction") + instance.precipitation = sensor_values.get("Precipitation") yield instance diff --git a/tests/components/opensensemap/fixtures/station.json b/tests/components/opensensemap/fixtures/station.json index ced77c463807..b9102adb4311 100644 --- a/tests/components/opensensemap/fixtures/station.json +++ b/tests/components/opensensemap/fixtures/station.json @@ -29,6 +29,96 @@ "value": "9.17", "createdAt": "2024-01-01T00:00:00.000Z" } + }, + { + "_id": "sensor-pm1", + "title": "PM1.0", + "unit": "µg/m³", + "sensorType": "SDS 011", + "lastMeasurement": { + "value": "3.10", + "createdAt": "2024-01-01T00:00:00.000Z" + } + }, + { + "_id": "sensor-temperature", + "title": "Temperature", + "unit": "°C", + "sensorType": "HDC1080", + "lastMeasurement": { + "value": "21.30", + "createdAt": "2024-01-01T00:00:00.000Z" + } + }, + { + "_id": "sensor-humidity", + "title": "Humidity", + "unit": "%", + "sensorType": "HDC1080", + "lastMeasurement": { + "value": "47.10", + "createdAt": "2024-01-01T00:00:00.000Z" + } + }, + { + "_id": "sensor-pressure", + "title": "Air Pressure", + "unit": "hPa", + "sensorType": "BMP280", + "lastMeasurement": { + "value": "1013.20", + "createdAt": "2024-01-01T00:00:00.000Z" + } + }, + { + "_id": "sensor-illuminance", + "title": "Illuminance", + "unit": "lx", + "sensorType": "TSL45315", + "lastMeasurement": { + "value": "12500.00", + "createdAt": "2024-01-01T00:00:00.000Z" + } + }, + { + "_id": "sensor-uv", + "title": "UV", + "unit": "UV Index", + "sensorType": "VEML6070", + "lastMeasurement": { + "value": "3.40", + "createdAt": "2024-01-01T00:00:00.000Z" + } + }, + { + "_id": "sensor-wind-speed", + "title": "Wind Speed", + "unit": "m/s", + "sensorType": "MISOL", + "lastMeasurement": { + "value": "2.50", + "createdAt": "2024-01-01T00:00:00.000Z" + } + }, + { + "_id": "sensor-wind-direction", + "title": "Wind Direction", + "unit": "°", + "sensorType": "MISOL", + "lastMeasurement": { + "value": "180.00", + "createdAt": "2024-01-01T00:00:00.000Z" + } + }, + { + "_id": "sensor-precipitation", + "title": "Precipitation", + "unit": "mm", + "sensorType": "MISOL", + "lastMeasurement": { + "value": "0.30", + "createdAt": "2024-01-01T00:00:00.000Z" + } } ] } diff --git a/tests/components/opensensemap/snapshots/test_sensor.ambr b/tests/components/opensensemap/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..fca574251dae --- /dev/null +++ b/tests/components/opensensemap/snapshots/test_sensor.ambr @@ -0,0 +1,517 @@ +# serializer version: 1 +# name: test_sensors[sensor.test_station_atmospheric_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_station_atmospheric_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Atmospheric pressure', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Atmospheric pressure', + 'platform': 'opensensemap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-station-id_air_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_station_atmospheric_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by openSenseMap', + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Test Station Atmospheric pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_station_atmospheric_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1013.2', + }) +# --- +# name: test_sensors[sensor.test_station_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_station_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Humidity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'opensensemap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-station-id_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.test_station_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by openSenseMap', + 'device_class': 'humidity', + 'friendly_name': 'Test Station Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_station_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.1', + }) +# --- +# name: test_sensors[sensor.test_station_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_station_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Illuminance', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'opensensemap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-station-id_illuminance', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensors[sensor.test_station_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by openSenseMap', + 'device_class': 'illuminance', + 'friendly_name': 'Test Station Illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.test_station_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12500.0', + }) +# --- +# name: test_sensors[sensor.test_station_pm1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_station_pm1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PM1', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM1', + 'platform': 'opensensemap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-station-id_pm1_0', + 'unit_of_measurement': 'μg/m³', + }) +# --- +# name: test_sensors[sensor.test_station_pm1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by openSenseMap', + 'device_class': 'pm1', + 'friendly_name': 'Test Station PM1', + 'state_class': , + 'unit_of_measurement': 'μg/m³', + }), + 'context': , + 'entity_id': 'sensor.test_station_pm1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.1', + }) +# --- +# name: test_sensors[sensor.test_station_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_station_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PM10', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'opensensemap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-station-id_pm10', + 'unit_of_measurement': 'μg/m³', + }) +# --- +# name: test_sensors[sensor.test_station_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by openSenseMap', + 'device_class': 'pm10', + 'friendly_name': 'Test Station PM10', + 'state_class': , + 'unit_of_measurement': 'μg/m³', + }), + 'context': , + 'entity_id': 'sensor.test_station_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.17', + }) +# --- +# name: test_sensors[sensor.test_station_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_station_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PM2.5', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'opensensemap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-station-id_pm2_5', + 'unit_of_measurement': 'μg/m³', + }) +# --- +# name: test_sensors[sensor.test_station_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by openSenseMap', + 'device_class': 'pm25', + 'friendly_name': 'Test Station PM2.5', + 'state_class': , + 'unit_of_measurement': 'μg/m³', + }), + 'context': , + 'entity_id': 'sensor.test_station_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.42', + }) +# --- +# name: test_sensors[sensor.test_station_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_station_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'opensensemap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-station-id_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_station_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by openSenseMap', + 'device_class': 'temperature', + 'friendly_name': 'Test Station Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_station_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.3', + }) +# --- +# name: test_sensors[sensor.test_station_wind_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_station_wind_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wind direction', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind direction', + 'platform': 'opensensemap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-station-id_wind_direction', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensors[sensor.test_station_wind_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by openSenseMap', + 'device_class': 'wind_direction', + 'friendly_name': 'Test Station Wind direction', + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.test_station_wind_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '180.0', + }) +# --- +# name: test_sensors[sensor.test_station_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_station_wind_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wind speed', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed', + 'platform': 'opensensemap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-station-id_wind_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_station_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by openSenseMap', + 'device_class': 'wind_speed', + 'friendly_name': 'Test Station Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_station_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.0', + }) +# --- diff --git a/tests/components/opensensemap/test_sensor.py b/tests/components/opensensemap/test_sensor.py new file mode 100644 index 000000000000..ef8465b445fb --- /dev/null +++ b/tests/components/opensensemap/test_sensor.py @@ -0,0 +1,152 @@ +"""Tests for the openSenseMap sensor platform.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Restrict the integration to the sensor platform for these tests.""" + with patch("homeassistant.components.opensensemap.PLATFORMS", [Platform.SENSOR]): + yield + + +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_opensensemap_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor state and registry entries via snapshot.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_missing_measurements_omit_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_opensensemap_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensors are not created for measurements absent from the station.""" + mock_opensensemap_api.air_pressure = None + mock_opensensemap_api.illuminance = None + mock_opensensemap_api.wind_speed = None + mock_opensensemap_api.wind_direction = None + mock_opensensemap_api.pm1_0 = None + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + keys = { + entry.unique_id.removeprefix(f"{mock_config_entry.unique_id}_") + for entry in entries + } + assert keys == {"pm2_5", "pm10", "temperature", "humidity"} + + +@pytest.mark.parametrize( + ( + "title", + "entity_id", + "station_unit", + "display_unit", + "expected_state", + ), + [ + pytest.param( + "Temperature", + "sensor.test_station_temperature", + "°F", + "°C", + (21.3 - 32) * 5 / 9, + id="temperature_fahrenheit_to_celsius", + ), + pytest.param( + "Wind Speed", + "sensor.test_station_wind_speed", + "km/h", + "km/h", + 2.5, + id="wind_speed_kmh", + ), + pytest.param( + "Air Pressure", + "sensor.test_station_atmospheric_pressure", + "Pa", + "hPa", + 1013.2 / 100, + id="air_pressure_pa_to_hpa", + ), + ], +) +async def test_unit_detection( + hass: HomeAssistant, + mock_opensensemap_api: AsyncMock, + mock_config_entry: MockConfigEntry, + title: str, + entity_id: str, + station_unit: str, + display_unit: str, + expected_state: float, +) -> None: + """Test units are detected from the station and converted for the metric system.""" + # The fixture reports metric units; override one sensor's unit (the values + # used here are the fixture's raw values) so it must be detected and + # converted, e.g. °F -> °C, km/h stays km/h, Pa -> hPa. + for sensor in mock_opensensemap_api.data["sensors"]: + if sensor["title"] == title: + sensor["unit"] = station_unit + break + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Ensure that the station's actual unit is detected and + # the value is correctly converted to HA's display unit. + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["unit_of_measurement"] == display_unit + assert float(state.state) == pytest.approx(expected_state, abs=0.01) + + +async def test_unit_detection_ignores_value_less_sensors( + hass: HomeAssistant, + mock_opensensemap_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unit detection skips value-less sensors, like the library does.""" + # A value-less duplicate would shadow the real °C sensor's unit (yielding °F) + # if detection didn't skip sensors without a measurement value. + mock_opensensemap_api.data["sensors"].insert( + 0, + {"title": "Temperature", "unit": "°F", "lastMeasurement": {}}, + ) + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Ensure that the real °C sensor is picked, not the value-less duplicate. + state = hass.states.get("sensor.test_station_temperature") + assert state is not None + assert state.attributes["unit_of_measurement"] == UnitOfTemperature.CELSIUS + assert float(state.state) == pytest.approx(21.3, abs=0.01) From d7af8ed2b34137d2ef7d64196d43d198519b15ee Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Thu, 11 Jun 2026 15:50:54 +0200 Subject: [PATCH 117/404] Bump mozart_api to 6.2.0.44.0 (#173514) --- .../components/bang_olufsen/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/bang_olufsen/conftest.py | 27 +++++++++++-------- tests/components/bang_olufsen/const.py | 2 +- .../bang_olufsen/test_media_player.py | 2 +- 5 files changed, 20 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/bang_olufsen/manifest.json b/homeassistant/components/bang_olufsen/manifest.json index b6116c678420..545d2c33f163 100644 --- a/homeassistant/components/bang_olufsen/manifest.json +++ b/homeassistant/components/bang_olufsen/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/bang_olufsen", "integration_type": "device", "iot_class": "local_push", - "requirements": ["mozart-api==5.3.1.108.2"], + "requirements": ["mozart-api==6.2.0.44.0"], "zeroconf": ["_bangolufsen._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 762cf7ee7a2f..a12f50f7abfe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1610,7 +1610,7 @@ motionblindsble==0.1.3 motioneye-client==0.3.14 # homeassistant.components.bang_olufsen -mozart-api==5.3.1.108.2 +mozart-api==6.2.0.44.0 # homeassistant.components.mullvad mullvad-api==1.0.0 diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py index 285c16fd5987..cc1b41b58249 100644 --- a/tests/components/bang_olufsen/conftest.py +++ b/tests/components/bang_olufsen/conftest.py @@ -2,6 +2,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch +from uuid import UUID from mozart_api.models import ( Action, @@ -254,8 +255,8 @@ def mock_mozart_client() -> Generator[AsyncMock]: dynamic_list=None, first_child_menu_item_id=None, label="Yle Radio Suomi Helsinki", - next_sibling_menu_item_id="0b4552f8-7ac6-5046-9d44-5410a815b8d6", - parent_menu_item_id="eee0c2d0-2b3a-4899-a708-658475c38926", + next_sibling_menu_item_id=UUID("0b4552f8-7ac6-5046-9d44-5410a815b8d6"), + parent_menu_item_id=UUID("eee0c2d0-2b3a-4899-a708-658475c38926"), available=None, content=ContentItem( categories=["music"], @@ -264,7 +265,7 @@ def mock_mozart_client() -> Generator[AsyncMock]: source=SourceTypeEnum(value="netRadio"), ), fixed=True, - id="b355888b-2cde-5f94-8592-d47b71d52a27", + id=UUID("b355888b-2cde-5f94-8592-d47b71d52a27"), ), # Has "hdmi" as category, so should be included in video sources "b6591565-80f4-4356-bcd9-c92ca247f0a9": RemoteMenuItem( @@ -293,8 +294,8 @@ def mock_mozart_client() -> Generator[AsyncMock]: dynamic_list="none", first_child_menu_item_id=None, label="HDMI A", - next_sibling_menu_item_id="0ba98974-7b1f-40dc-bc48-fbacbb0f1793", - parent_menu_item_id="b66c835b-6b98-4400-8f84-6348043792c7", + next_sibling_menu_item_id=UUID("0ba98974-7b1f-40dc-bc48-fbacbb0f1793"), + parent_menu_item_id=UUID("b66c835b-6b98-4400-8f84-6348043792c7"), available=True, content=ContentItem( categories=["hdmi"], @@ -303,7 +304,7 @@ def mock_mozart_client() -> Generator[AsyncMock]: source=SourceTypeEnum(value="tv"), ), fixed=False, - id="b6591565-80f4-4356-bcd9-c92ca247f0a9", + id=UUID("b6591565-80f4-4356-bcd9-c92ca247f0a9"), ), # The parent remote menu item. Has the TV label and # should therefore not be included in video sources @@ -312,14 +313,14 @@ def mock_mozart_client() -> Generator[AsyncMock]: scene_list=None, disabled=False, dynamic_list="none", - first_child_menu_item_id="b6591565-80f4-4356-bcd9-c92ca247f0a9", + first_child_menu_item_id=UUID("b6591565-80f4-4356-bcd9-c92ca247f0a9"), label="TV", - next_sibling_menu_item_id="0c4547fe-d3cc-4348-a425-473595b8c9fb", + next_sibling_menu_item_id=UUID("0c4547fe-d3cc-4348-a425-473595b8c9fb"), parent_menu_item_id=None, available=True, content=None, fixed=True, - id="b66c835b-6b98-4400-8f84-6348043792c7", + id=UUID("b66c835b-6b98-4400-8f84-6348043792c7"), ), # Has an empty content, so should not be included "64c9da45-3682-44a4-8030-09ed3ef44160": RemoteMenuItem( @@ -330,11 +331,11 @@ def mock_mozart_client() -> Generator[AsyncMock]: first_child_menu_item_id=None, label="ListeningPosition", next_sibling_menu_item_id=None, - parent_menu_item_id="0c4547fe-d3cc-4348-a425-473595b8c9fb", + parent_menu_item_id=UUID("0c4547fe-d3cc-4348-a425-473595b8c9fb"), available=True, content=None, fixed=True, - id="64c9da45-3682-44a4-8030-09ed3ef44160", + id=UUID("64c9da45-3682-44a4-8030-09ed3ef44160"), ), } client.get_beolink_peers = AsyncMock() @@ -343,11 +344,13 @@ def mock_mozart_client() -> Generator[AsyncMock]: friendly_name=TEST_FRIENDLY_NAME_3, jid=TEST_JID_3, ip_address=TEST_HOST_3, + audio_transport="v2", ), BeolinkPeer( friendly_name=TEST_FRIENDLY_NAME_4, jid=TEST_JID_4, ip_address=TEST_HOST_4, + audio_transport="v2", ), ] client.get_beolink_listeners = AsyncMock() @@ -356,11 +359,13 @@ def mock_mozart_client() -> Generator[AsyncMock]: friendly_name=TEST_FRIENDLY_NAME_3, jid=TEST_JID_3, ip_address=TEST_HOST_3, + audio_transport="v2", ), BeolinkPeer( friendly_name=TEST_FRIENDLY_NAME_4, jid=TEST_JID_4, ip_address=TEST_HOST_4, + audio_transport="v2", ), ] diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py index 495ffab5ebd9..441d5753653e 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -203,7 +203,7 @@ TEST_PLAYBACK_METADATA_VIDEO = PlaybackContentMetadata( title="HDMI A", source_internal_id="hdmi_1", output_channel_processing="TrueImage", - output_Channels="5.0.2", + output_channels="5.0.2", ) TEST_PLAYBACK_ERROR = PlaybackError(error="Test error") TEST_PLAYBACK_PROGRESS = PlaybackProgress(progress=123) diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 6d8820795539..3a4d7a0829e0 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -582,7 +582,7 @@ async def test_async_update_beolink_listener( playback_metadata_callback( PlaybackContentMetadata( remote_leader=BeolinkLeader( - friendly_name=TEST_FRIENDLY_NAME_2, jid=TEST_JID_2 + friendly_name=TEST_FRIENDLY_NAME_2, jid=TEST_JID_2, audio_transport="v2" ) ) ) From ee30f6c085ceda83338b9751d4a7e05c8a7167e8 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 11 Jun 2026 15:52:06 +0200 Subject: [PATCH 118/404] MELCloud Home follow-up PR to refactor small parts (#173515) --- homeassistant/components/melcloud_home/climate.py | 5 ++--- homeassistant/components/melcloud_home/config_flow.py | 4 +++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/melcloud_home/climate.py b/homeassistant/components/melcloud_home/climate.py index 2b1b0871a1e4..f4766cc30aca 100644 --- a/homeassistant/components/melcloud_home/climate.py +++ b/homeassistant/components/melcloud_home/climate.py @@ -103,7 +103,6 @@ async def async_setup_entry( async_add_entities(ATAClimateEntity(coordinator, unit) for unit in units) def _async_add_new_atw_units(units: list[ATWUnit]) -> None: - # Erwin: create zone 1 for all units, and zone 2 only when the unit supports it. async_add_entities( ATWZoneClimateEntity(coordinator, unit, zone_number) for unit in units @@ -186,12 +185,12 @@ class ATAClimateEntity(MelCloudHomeATAUnitEntity, ClimateEntity): @property def current_temperature(self) -> float | None: """Return the current room temperature.""" - return self.unit.room_temperature if self.unit else None + return self.unit.room_temperature @property def target_temperature(self) -> float | None: """Return the target temperature.""" - return self.unit.set_temperature if self.unit else None + return self.unit.set_temperature @property def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/melcloud_home/config_flow.py b/homeassistant/components/melcloud_home/config_flow.py index 16f25405a58c..0122ea83906d 100644 --- a/homeassistant/components/melcloud_home/config_flow.py +++ b/homeassistant/components/melcloud_home/config_flow.py @@ -26,7 +26,9 @@ _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_EMAIL): str, + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig(type=TextSelectorType.EMAIL, autocomplete="username") + ), vol.Required(CONF_PASSWORD): TextSelector( TextSelectorConfig(type=TextSelectorType.PASSWORD) ), From fdb15ce2d741b8524e4d62bf8391091595483139 Mon Sep 17 00:00:00 2001 From: bkobus-bbx Date: Thu, 11 Jun 2026 16:06:07 +0200 Subject: [PATCH 119/404] Add support for inputSensor Blebox devices (#169841) --- .../components/blebox/binary_sensor.py | 3 + tests/components/blebox/test_binary_sensor.py | 70 ++++++++++++++++--- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/blebox/binary_sensor.py b/homeassistant/components/blebox/binary_sensor.py index 6087546ef6b1..105cc1b2c18d 100644 --- a/homeassistant/components/blebox/binary_sensor.py +++ b/homeassistant/components/blebox/binary_sensor.py @@ -25,6 +25,9 @@ BINARY_SENSOR_TYPES = ( key="open", device_class=BinarySensorDeviceClass.WINDOW, ), + BinarySensorEntityDescription( + key="input", + ), ) diff --git a/tests/components/blebox/test_binary_sensor.py b/tests/components/blebox/test_binary_sensor.py index 814c06cab051..06e9021f6579 100644 --- a/tests/components/blebox/test_binary_sensor.py +++ b/tests/components/blebox/test_binary_sensor.py @@ -45,23 +45,75 @@ def open_sensor_fixture() -> tuple[AsyncMock, str]: return feature, "binary_sensor.my_open_sensor_opensensor_0_open" +@pytest.fixture(name="inputsensor") +def inputsensor_fixture() -> tuple[AsyncMock, str]: + """Return a default inputSensor fixture.""" + feature: AsyncMock = mock_feature( + "binary_sensors", + blebox_uniapi.binary_sensor.Input, + unique_id="BleBox-inputSensorD-aa11bb22cc33-0.input", + full_name="inputSensorD-0.input", + device_class="input", + ) + product = feature.product + type(product).name = PropertyMock(return_value="My input sensor") + type(product).model = PropertyMock(return_value="inputSensorD") + return feature, "binary_sensor.my_input_sensor_inputsensord_0_input" + + +@pytest.mark.parametrize( + ( + "fixture_name", + "unique_id", + "expected_name", + "expected_device_class", + "expected_state", + "expected_device_name", + ), + [ + pytest.param( + "rainsensor", + "BleBox-windRainSensor-ea68e74f4f49-0.rain", + "My rain sensor windRainSensor-0.rain", + BinarySensorDeviceClass.MOISTURE, + STATE_ON, + "My rain sensor", + id="moisture", + ), + pytest.param( + "inputsensor", + "BleBox-inputSensorD-aa11bb22cc33-0.input", + "My input sensor inputSensorD-0.input", + None, + STATE_ON, + "My input sensor", + id="input", + ), + ], +) async def test_init( - rainsensor: AsyncMock, device_registry: dr.DeviceRegistry, hass: HomeAssistant + hass: HomeAssistant, + fixture_name: str, + unique_id: str, + expected_name: str, + expected_device_class: BinarySensorDeviceClass | None, + expected_state: str, + expected_device_name: str, + device_registry: dr.DeviceRegistry, + request: pytest.FixtureRequest, ) -> None: """Test binary_sensor initialisation.""" - _, entity_id = rainsensor + _, entity_id = request.getfixturevalue(fixture_name) entry = await async_setup_entity(hass, entity_id) - assert entry.unique_id == "BleBox-windRainSensor-ea68e74f4f49-0.rain" + assert entry.unique_id == unique_id state = hass.states.get(entity_id) - assert state.name == "My rain sensor windRainSensor-0.rain" - - assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.MOISTURE - assert state.state == STATE_ON + assert state.name == expected_name + assert state.attributes.get(ATTR_DEVICE_CLASS) == expected_device_class + assert state.state == expected_state device = device_registry.async_get(entry.device_id) - - assert device.name == "My rain sensor" + assert device.name == expected_device_name async def test_open_sensor_init( From dfa40f807e9fe112928d03f249fddbd56330af78 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:15:26 +0200 Subject: [PATCH 120/404] Remove positional message strings when translation_key is set in manual (#173393) --- homeassistant/components/manual/alarm_control_panel.py | 2 -- tests/components/manual/test_alarm_control_panel.py | 9 ++++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index 230df3f3dcec..c1b9b8788125 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -422,9 +422,7 @@ class ManualAlarm(AlarmControlPanelEntity, RestoreEntity): }, ) - # pylint: disable-next=home-assistant-exception-message-with-translation raise ServiceValidationError( - "Invalid alarm code provided", translation_domain=DOMAIN, translation_key="invalid_code", ) diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index 5eb77c7a8f61..7dbda38ae1ab 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -240,7 +240,7 @@ async def test_with_invalid_code(hass: HomeAssistant, service, expected_state) - assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED - with pytest.raises(ServiceValidationError, match=r"^Invalid alarm code provided$"): + with pytest.raises(ServiceValidationError) as err: await hass.services.async_call( alarm_control_panel.DOMAIN, service, @@ -250,6 +250,7 @@ async def test_with_invalid_code(hass: HomeAssistant, service, expected_state) - }, blocking=True, ) + assert err.value.translation_key == "invalid_code" assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED @@ -1108,8 +1109,9 @@ async def test_disarm_during_trigger_with_invalid_code(hass: HomeAssistant) -> N assert hass.states.get(entity_id).state == AlarmControlPanelState.PENDING - with pytest.raises(ServiceValidationError, match=r"^Invalid alarm code provided$"): + with pytest.raises(ServiceValidationError) as err: await common.async_alarm_disarm(hass, entity_id=entity_id) + assert err.value.translation_key == "invalid_code" assert hass.states.get(entity_id).state == AlarmControlPanelState.PENDING @@ -1226,8 +1228,9 @@ async def test_disarm_with_template_code(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert state.state == AlarmControlPanelState.ARMED_HOME - with pytest.raises(ServiceValidationError, match=r"^Invalid alarm code provided$"): + with pytest.raises(ServiceValidationError) as err: await common.async_alarm_disarm(hass, "def") + assert err.value.translation_key == "invalid_code" state = hass.states.get(entity_id) assert state.state == AlarmControlPanelState.ARMED_HOME From ea5e8e798204184af8aa575955a53e0f61f684d4 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 11 Jun 2026 16:31:35 +0200 Subject: [PATCH 121/404] Rephrase aw check requirements (#171676) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/check-requirements.lock.yml | 36 +- .github/workflows/check-requirements.md | 386 ++++++------------ 2 files changed, 152 insertions(+), 270 deletions(-) diff --git a/.github/workflows/check-requirements.lock.yml b/.github/workflows/check-requirements.lock.yml index a80997fa2422..b932dc189912 100644 --- a/.github/workflows/check-requirements.lock.yml +++ b/.github/workflows/check-requirements.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"75b8b624ba0c144fb4b28cba143d16a47c30de8afae568fa3256c6febe01a68a","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"} +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"e4fcdd04986da27ef3059faa0cea3d64bb879fe12085ebfdec0041bbc31ec181","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"d3abfe96a194bce3a523ed2093ddedd5704cdf62","version":"v0.74.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.46"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.9","digest":"sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.9@sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} # ___ _ _ # / _ \ | | (_) @@ -59,15 +59,13 @@ permissions: {} concurrency: cancel-in-progress: true - group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }} + group: ${{ github.workflow }}-${{ github.event.workflow_run.id }} run-name: "Check requirements (AW)" jobs: activation: - needs: - - extract_pr_number - - pre_activation + needs: pre_activation # zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation if: > (needs.pre_activation.outputs.activated == 'true') && (github.event_name != 'workflow_run' || github.event.workflow_run.repository.id == github.repository_id && @@ -191,20 +189,20 @@ jobs: run: | bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" { - cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF' + cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF' - GH_AW_PROMPT_198418d99edc7d5b_EOF + GH_AW_PROMPT_2fc32253e89940f3_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF' + cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF' Tools: add_comment, missing_tool, missing_data, noop - GH_AW_PROMPT_198418d99edc7d5b_EOF + GH_AW_PROMPT_2fc32253e89940f3_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" - cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF' + cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF' The following GitHub context information is available for this workflow: {{#if github.actor}} @@ -233,12 +231,12 @@ jobs: {{/if}} - GH_AW_PROMPT_198418d99edc7d5b_EOF + GH_AW_PROMPT_2fc32253e89940f3_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF' + cat << 'GH_AW_PROMPT_2fc32253e89940f3_EOF' {{#runtime-import .github/workflows/check-requirements.md}} - GH_AW_PROMPT_198418d99edc7d5b_EOF + GH_AW_PROMPT_2fc32253e89940f3_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -323,7 +321,6 @@ jobs: permissions: actions: read contents: read - issues: read pull-requests: read concurrency: group: "gh-aw-copilot-${{ github.workflow }}" @@ -453,9 +450,9 @@ jobs: mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF' + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_eaae5443153d0b45_EOF' {"add_comment":{"max":1,"target":"${{ needs.extract_pr_number.outputs.pr_number }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} - GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF + GH_AW_SAFE_OUTPUTS_CONFIG_eaae5443153d0b45_EOF - name: Generate Safe Outputs Tools env: GH_AW_TOOLS_META_JSON: | @@ -647,7 +644,7 @@ jobs: mkdir -p /home/runner/.copilot GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) - cat << GH_AW_MCP_CONFIG_175174907e5a28b4_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + cat << GH_AW_MCP_CONFIG_d99df59573a98681_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" { "mcpServers": { "github": { @@ -657,7 +654,7 @@ jobs: "GITHUB_HOST": "\${GITHUB_SERVER_URL}", "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", "GITHUB_READ_ONLY": "1", - "GITHUB_TOOLSETS": "context,repos,issues,pull_requests,actions" + "GITHUB_TOOLSETS": "repos,pull_requests" }, "guard-policies": { "allow-only": { @@ -691,7 +688,7 @@ jobs: "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" } } - GH_AW_MCP_CONFIG_175174907e5a28b4_EOF + GH_AW_MCP_CONFIG_d99df59573a98681_EOF - name: Mount MCP servers as CLIs id: mount-mcp-clis continue-on-error: true @@ -1284,6 +1281,7 @@ jobs: } extract_pr_number: + needs: activation if: github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/check-requirements.md b/.github/workflows/check-requirements.md index 3174d7ecf6da..1e96cdede0aa 100644 --- a/.github/workflows/check-requirements.md +++ b/.github/workflows/check-requirements.md @@ -6,7 +6,6 @@ on: permissions: contents: read actions: read - issues: read pull-requests: read network: allowed: @@ -14,7 +13,7 @@ network: tools: web-fetch: {} github: - toolsets: [default, actions] + toolsets: [repos, pull_requests] min-integrity: unapproved safe-outputs: add-comment: @@ -44,7 +43,7 @@ jobs: PR=$(jq -r '.pr_number' /tmp/deterministic/results.json) echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}" concurrency: - group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }} + group: ${{ github.workflow }}-${{ github.event.workflow_run.id }} cancel-in-progress: true steps: - name: Download deterministic-results artifact @@ -83,296 +82,181 @@ description: > # Check requirements (AW) -You are a code review assistant for the Home Assistant project. The -deterministic stage has already evaluated every check it can on its own -and produced an artifact containing the PR number, per-package check -results, and a pre-rendered comment with placeholders. **Your only job is -to read that artifact, resolve any `needs_agent` checks, and post the -final comment.** +You are a code-review assistant for Home Assistant. The deterministic +stage already evaluated every check it can and produced an artifact at +`/tmp/gh-aw/deterministic/results.json`. Your only job is to resolve any +`needs_agent` checks and post the rendered comment. -## Step 1 — Read the deterministic-stage artifact +## Step 1 — Read the artifact -The deterministic stage uploaded its results to the runner at -`/tmp/gh-aw/deterministic/results.json`. +Read the JSON directly for the full schema. Key fields: -The JSON has this shape: +- `pr_number`, `needs_agent` (bool), `packages[]`, `rendered_comment`. +- Each `package`: `name`, `old_version` (`null` if new), `new_version`, + `repo_url`, `publisher_kind`, `checks` (keyed by check-kind, each + with `status` of `pass`/`warn`/`fail`/`needs_agent` and `details`). +- `rendered_comment` contains, for each `needs_agent` check, two + placeholders to replace: + - `{{CHECK_CELL::}}` → exactly one of `✅`, `⚠️`, `❌`. + - `{{CHECK_DETAIL::}}` → ` ` + (the bullet's `- **