diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 81696c4a2d7a5..ccabb2ac37bb1 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -497,7 +497,7 @@ jobs: python -m build - name: Upload package to PyPI - uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: skip-existing: true @@ -550,7 +550,7 @@ jobs: - name: Generate artifact attestation if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' - uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 with: subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} diff --git a/homeassistant/brands/fireavert.json b/homeassistant/brands/fireavert.json new file mode 100644 index 0000000000000..0e6e54ac4f060 --- /dev/null +++ b/homeassistant/brands/fireavert.json @@ -0,0 +1,5 @@ +{ + "domain": "fireavert", + "name": "FireAvert", + "iot_standards": ["zwave"] +} diff --git a/homeassistant/components/button/trigger.py b/homeassistant/components/button/trigger.py index 8d5402401c79c..90cb7a50b2eb8 100644 --- a/homeassistant/components/button/trigger.py +++ b/homeassistant/components/button/trigger.py @@ -1,5 +1,6 @@ """Provides triggers for buttons.""" +from homeassistant.components.input_button import DOMAIN as INPUT_BUTTON_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger @@ -10,7 +11,10 @@ class ButtonPressedTrigger(StatelessEntityTriggerBase): """Trigger for button entity presses.""" - _domain_specs = {DOMAIN: DomainSpec()} + _domain_specs = { + DOMAIN: DomainSpec(), + INPUT_BUTTON_DOMAIN: DomainSpec(), + } TRIGGERS: dict[str, type[Trigger]] = { diff --git a/homeassistant/components/button/triggers.yaml b/homeassistant/components/button/triggers.yaml index 520a0bc1f200f..4a8f63dff18f7 100644 --- a/homeassistant/components/button/triggers.yaml +++ b/homeassistant/components/button/triggers.yaml @@ -1,4 +1,5 @@ pressed: target: entity: - domain: button + - domain: button + - domain: input_button diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 635c7a7cf3409..49d899eb02ee6 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -601,6 +601,10 @@ async def async_scan_devices(self, now: datetime | None = None) -> None: ) self.mesh_role = MeshRoles.NONE for mac, info in hosts.items(): + # The box lists its own LAN MAC in the Hosts table; skip it so it + # is not tracked as a child of itself, as the mesh path does. + if dr.format_mac(mac) == self.mac: + continue if self.manage_device_info(info, mac, consider_home): new_device = True await self.async_send_signal_device_update(new_device) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 3afef37f8df43..c7f7a8046bed1 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260729.4"] + "requirements": ["home-assistant-frontend==20260729.5"] } diff --git a/homeassistant/components/google_health/manifest.json b/homeassistant/components/google_health/manifest.json index 62fcffbb04b58..45aabbf55b2c4 100644 --- a/homeassistant/components/google_health/manifest.json +++ b/homeassistant/components/google_health/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/google_health", "integration_type": "service", "iot_class": "cloud_polling", - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["google-health-api==0.8.0"] } diff --git a/homeassistant/components/google_health/quality_scale.yaml b/homeassistant/components/google_health/quality_scale.yaml index 79bcc7476f426..b45264f2ff40d 100644 --- a/homeassistant/components/google_health/quality_scale.yaml +++ b/homeassistant/components/google_health/quality_scale.yaml @@ -72,7 +72,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: This integration does not raise repair issues. diff --git a/homeassistant/components/govee_ble/binary_sensor.py b/homeassistant/components/govee_ble/binary_sensor.py index b1059e4c69bb9..ed9ebdc7136a0 100644 --- a/homeassistant/components/govee_ble/binary_sensor.py +++ b/homeassistant/components/govee_ble/binary_sensor.py @@ -68,9 +68,10 @@ def sensor_update_to_bluetooth_data_update( device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value for device_key, sensor_values in sensor_update.binary_entity_values.items() }, + # None overrides names restored from storage, {} would keep them entity_names={ - device_key_to_bluetooth_entity_key(device_key): sensor_values.name - for device_key, sensor_values in sensor_update.binary_entity_values.items() + device_key_to_bluetooth_entity_key(device_key): None + for device_key in sensor_update.binary_entity_values }, ) diff --git a/homeassistant/components/govee_ble/sensor.py b/homeassistant/components/govee_ble/sensor.py index d92be7bc2d1e4..896a1b0af1a50 100644 --- a/homeassistant/components/govee_ble/sensor.py +++ b/homeassistant/components/govee_ble/sensor.py @@ -79,6 +79,32 @@ ), } +PROBE_SENSOR_DESCRIPTIONS = { + f"{translation_key}_{probe_id}": SensorEntityDescription( + key=f"{translation_key}_{probe_id}", + translation_key=translation_key, + translation_placeholders={"probe_id": str(probe_id)}, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ) + for probe_id in range(1, 7) + for translation_key in ( + "temperature_probe", + "temperature_alarm_probe", + "low_temperature_alarm_probe", + ) +} | { + "humidity_probe_1": SensorEntityDescription( + key="humidity_probe_1", + translation_key="humidity_probe", + translation_placeholders={"probe_id": "1"}, + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=UnitOfRatio.PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ) +} + def sensor_update_to_bluetooth_data_update( sensor_update: SensorUpdate, @@ -90,9 +116,12 @@ def sensor_update_to_bluetooth_data_update( for device_id, device_info in sensor_update.devices.items() }, entity_descriptions={ - device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[ - (description.device_class, description.native_unit_of_measurement) - ] + device_key_to_bluetooth_entity_key(device_key): ( + PROBE_SENSOR_DESCRIPTIONS.get(device_key.key) + or SENSOR_DESCRIPTIONS[ + (description.device_class, description.native_unit_of_measurement) + ] + ) for device_key, description in sensor_update.entity_descriptions.items() if description.device_class and description.native_unit_of_measurement }, @@ -100,9 +129,10 @@ def sensor_update_to_bluetooth_data_update( device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value for device_key, sensor_values in sensor_update.entity_values.items() }, + # None overrides names restored from storage, {} would keep them entity_names={ - device_key_to_bluetooth_entity_key(device_key): sensor_values.name - for device_key, sensor_values in sensor_update.entity_values.items() + device_key_to_bluetooth_entity_key(device_key): None + for device_key in sensor_update.entity_values }, ) diff --git a/homeassistant/components/govee_ble/strings.json b/homeassistant/components/govee_ble/strings.json index 09fa72087728a..3b27991efbc34 100644 --- a/homeassistant/components/govee_ble/strings.json +++ b/homeassistant/components/govee_ble/strings.json @@ -99,6 +99,20 @@ } } } + }, + "sensor": { + "humidity_probe": { + "name": "Humidity probe {probe_id}" + }, + "low_temperature_alarm_probe": { + "name": "Low temperature alarm probe {probe_id}" + }, + "temperature_alarm_probe": { + "name": "Temperature alarm probe {probe_id}" + }, + "temperature_probe": { + "name": "Temperature probe {probe_id}" + } } } } diff --git a/homeassistant/components/hortimax/entity.py b/homeassistant/components/hortimax/entity.py index ce2f1d1429320..b1bf5ca98b904 100644 --- a/homeassistant/components/hortimax/entity.py +++ b/homeassistant/components/hortimax/entity.py @@ -2,6 +2,7 @@ from aiohortos import Readout +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -13,7 +14,7 @@ class HortimaxEntity(CoordinatorEntity[HortimaxCoordinator]): """An entity backed by one readout of a HortOS source. Sources (a weather station, a ventilation group, ...) each become their own - device, linked to their controller through ``via_device``. + device, linked to their controller through ``via_device_id``. """ _attr_has_entity_name = True @@ -38,7 +39,11 @@ def __init__( ), model=source.type, manufacturer=MANUFACTURER, - via_device=(DOMAIN, device_id), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, device_id), + config_entry_id=coordinator.config_entry.entry_id, + ), ) @property diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 40a788c06d8d0..95fc8ece1e2bc 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -19,7 +19,6 @@ ATTR_SERIAL_NUMBER, ATTR_SUGGESTED_AREA, ATTR_SW_VERSION, - ATTR_VIA_DEVICE, CONF_DEVICE, CONF_ENTITY_CATEGORY, CONF_ICON, @@ -29,6 +28,8 @@ CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, + EntityCapabilityAttribute, + EntityStateAttribute, ) from homeassistant.core import Event, HassJobType, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -134,24 +135,24 @@ _LOGGER = logging.getLogger(__name__) MQTT_ATTRIBUTES_BLOCKED = { - "assumed_state", "available", - "device_class", "device_info", "entity_category", "entity_id", - "entity_picture", "entity_registry_enabled_default", "extra_state_attributes", "force_update", - "group_entities", - "icon", - "friendly_name", "should_poll", "state", - "supported_features", "unique_id", - "unit_of_measurement", + EntityCapabilityAttribute.GROUP_ENTITIES, + EntityStateAttribute.ASSUMED_STATE, + EntityStateAttribute.DEVICE_CLASS, + EntityStateAttribute.ENTITY_PICTURE, + EntityStateAttribute.FRIENDLY_NAME, + EntityStateAttribute.ICON, + EntityStateAttribute.SUPPORTED_FEATURES, + EntityStateAttribute.UNIT_OF_MEASUREMENT, } PUBLISH_KWARGS = (CONF_MESSAGE_EXPIRY_INTERVAL,) @@ -1299,9 +1300,6 @@ def device_info_from_specifications( if CONF_SW_VERSION in specifications: info[ATTR_SW_VERSION] = specifications[CONF_SW_VERSION] - if CONF_VIA_DEVICE in specifications: - info[ATTR_VIA_DEVICE] = (DOMAIN, specifications[CONF_VIA_DEVICE]) - if CONF_SUGGESTED_AREA in specifications: info[ATTR_SUGGESTED_AREA] = specifications[CONF_SUGGESTED_AREA] @@ -1311,33 +1309,67 @@ def device_info_from_specifications( return info +def _via_device_identifier( + specifications: dict[str, Any] | None, +) -> tuple[str, str] | None: + """Return the device registry identifier of the referenced via device.""" + if not specifications or CONF_VIA_DEVICE not in specifications: + return None + return (DOMAIN, specifications[CONF_VIA_DEVICE]) + + @callback def ensure_via_device_exists( - hass: HomeAssistant, device_info: DeviceInfo | None, config_entry: ConfigEntry + hass: HomeAssistant, + specifications: dict[str, Any] | None, + config_entry: ConfigEntry, ) -> None: - """Ensure the via device is in the device registry.""" - if ( - device_info is None - or CONF_VIA_DEVICE not in device_info - or (device_registry := dr.async_get(hass)).async_get_device_by_identifier( - device_info["via_device"], config_entry.entry_id - ) + """Ensure the via device is in the device registry. + + MQTT discovery can announce a child device before its via device, so the + referenced parent is stub-created here when it does not yet exist. + """ + if (identifier := _via_device_identifier(specifications)) is None: + return + + device_registry = dr.async_get(hass) + if device_registry.async_get_device_by_identifier( + identifier, config_entry.entry_id ): return - # Ensure the via device exists in the device registry _LOGGER.debug( - "Device identifier %s via_device reference from device_info %s " - "not found in the Device Registry, creating new entry", - device_info["via_device"], - device_info, + "Device identifier %s referenced as via_device not found in the " + "Device Registry, creating new entry", + identifier, ) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={device_info["via_device"]}, + identifiers={identifier}, ) +@callback +def _resolve_via_device_id( + hass: HomeAssistant, + specifications: dict[str, Any] | None, + config_entry: ConfigEntry, +) -> str | None: + """Resolve the referenced via device to its device registry id. + + Best-effort read-only lookup: returns None when no via device is referenced + or the referenced device is not (yet) registered. It never raises, so a + missing parent cannot abort entity setup given MQTT's non-deterministic + discovery order. + """ + if (identifier := _via_device_identifier(specifications)) is None: + return None + via_device = dr.async_get(hass).async_get_device_by_identifier( + identifier, config_entry.entry_id + ) + return via_device.id if via_device else None + + class MqttEntityDeviceInfo(Entity): """Mixin used for mqtt platforms that support the device registry.""" @@ -1353,10 +1385,12 @@ def device_info_discovery_update(self, config: DiscoveryInfoType) -> None: self._device_specifications = config.get(CONF_DEVICE) device_registry = dr.async_get(self.hass) config_entry_id = self._config_entry.entry_id + ensure_via_device_exists( + self.hass, self._device_specifications, self._config_entry + ) device_info = self.device_info if device_info is not None: - ensure_via_device_exists(self.hass, device_info, self._config_entry) device_registry.async_get_or_create( config_entry_id=config_entry_id, **device_info ) @@ -1365,7 +1399,14 @@ def device_info_discovery_update(self, config: DiscoveryInfoType) -> None: @override def device_info(self) -> DeviceInfo | None: """Return a device description for device registry.""" - return device_info_from_specifications(self._device_specifications) + info = device_info_from_specifications(self._device_specifications) + if info is not None and ( + via_device_id := _resolve_via_device_id( + self.hass, self._device_specifications, self._config_entry + ) + ): + info["via_device_id"] = via_device_id + return info class MqttEntity( @@ -1412,7 +1453,9 @@ def __init__( self, hass, discovery_data, self.discovery_update ) MqttEntityDeviceInfo.__init__(self, config.get(CONF_DEVICE), config_entry) - ensure_via_device_exists(self.hass, self.device_info, self._config_entry) + ensure_via_device_exists( + self.hass, self._device_specifications, self._config_entry + ) def _init_entity_registry(self, discovery_data: DiscoveryInfoType | None) -> None: """Set entity_id from default_entity_id if defined in config. @@ -1731,11 +1774,14 @@ def update_device( device: DeviceEntry | None = None device_registry = dr.async_get(hass) config_entry_id = config_entry.entry_id - device_info = device_info_from_specifications(config[CONF_DEVICE]) + specifications = config[CONF_DEVICE] - ensure_via_device_exists(hass, device_info, config_entry) + ensure_via_device_exists(hass, specifications, config_entry) + device_info = device_info_from_specifications(specifications) if config_entry_id is not None and device_info is not None: + if via_device_id := _resolve_via_device_id(hass, specifications, config_entry): + device_info["via_device_id"] = via_device_id update_device_info = cast(dict[str, Any], device_info) update_device_info["config_entry_id"] = config_entry_id device = device_registry.async_get_or_create(**update_device_info) diff --git a/homeassistant/components/nintendo_parental_controls/services.py b/homeassistant/components/nintendo_parental_controls/services.py index cab7f00bdfb0a..05dd4742a05af 100644 --- a/homeassistant/components/nintendo_parental_controls/services.py +++ b/homeassistant/components/nintendo_parental_controls/services.py @@ -9,7 +9,11 @@ from homeassistant.const import ATTR_DEVICE_ID, CONF_PIN from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + service, +) from .const import ATTR_BONUS_TIME, DOMAIN from .coordinator import NintendoParentalControlsConfigEntry @@ -40,11 +44,12 @@ def async_setup_services( } ), ) - hass.services.async_register( - domain=DOMAIN, - service=NintendoParentalServices.UPDATE_PIN_CODE, - service_func=async_update_pin_code, - schema=vol.Schema( + service.async_register_admin_service( + hass, + DOMAIN, + NintendoParentalServices.UPDATE_PIN_CODE, + async_update_pin_code, + vol.Schema( { vol.Required(ATTR_DEVICE_ID): cv.string, vol.Required(CONF_PIN): cv.string, diff --git a/homeassistant/components/overseerr/__init__.py b/homeassistant/components/overseerr/__init__.py index 87299859bb8c5..44466ad4a51fd 100644 --- a/homeassistant/components/overseerr/__init__.py +++ b/homeassistant/components/overseerr/__init__.py @@ -1,6 +1,5 @@ """The Overseerr integration.""" -import json from typing import cast from aiohttp.hdrs import METH_POST @@ -133,7 +132,7 @@ async def check_need_change(self) -> bool: return ( not current_config.enabled or current_config.options.webhook_url not in self.webhook_urls - or current_config.options.json_payload != json.loads(JSON_PAYLOAD) + or current_config.options.json_payload != JSON_PAYLOAD or current_config.types != REGISTERED_NOTIFICATIONS ) diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py index a48ac7669b414..8a87aacd9aee0 100644 --- a/homeassistant/components/overseerr/const.py +++ b/homeassistant/components/overseerr/const.py @@ -32,25 +32,48 @@ | NotificationType.ISSUE_RESOLVED | NotificationType.ISSUE_REOPENED ) -JSON_PAYLOAD = ( - '"{\\"notification_type\\":\\"{{notification_type}}\\",\\"subject\\":\\"{{subject}' - '}\\",\\"message\\":\\"{{message}}\\",\\"image\\":\\"{{image}}\\",\\"{{media}}\\":' - '{\\"media_type\\":\\"{{media_type}}\\",\\"tmdb_id\\":\\"{{media_tmdbid}}\\",\\"t' - 'vdb_id\\":\\"{{media_tvdbid}}\\",\\"status\\":\\"{{media_status}}\\",\\"status4k' - '\\":\\"{{media_status4k}}\\"},\\"{{request}}\\":{\\"request_id\\":\\"{{request_id' - '}}\\",\\"requested_by_email\\":\\"{{requestedBy_email}}\\",\\"requested_by_userna' - 'me\\":\\"{{requestedBy_username}}\\",\\"requested_by_avatar\\":\\"{{requestedBy_a' - 'vatar}}\\",\\"requested_by_settings_discord_id\\":\\"{{requestedBy_settings_disco' - 'rdId}}\\",\\"requested_by_settings_telegram_chat_id\\":\\"{{requestedBy_settings_' - 'telegramChatId}}\\"},\\"{{issue}}\\":{\\"issue_id\\":\\"{{issue_id}}\\",\\"issue_' - 'type\\":\\"{{issue_type}}\\",\\"issue_status\\":\\"{{issue_status}}\\",\\"reporte' - 'd_by_email\\":\\"{{reportedBy_email}}\\",\\"reported_by_username\\":\\"{{reported' - 'By_username}}\\",\\"reported_by_avatar\\":\\"{{reportedBy_avatar}}\\",\\"reported' - '_by_settings_discord_id\\":\\"{{reportedBy_settings_discordId}}\\",\\"reported_by' - '_settings_telegram_chat_id\\":\\"{{reportedBy_settings_telegramChatId}}\\"},\\"{{' - 'comment}}\\":{\\"comment_message\\":\\"{{comment_message}}\\",\\"commented_by_ema' - 'il\\":\\"{{commentedBy_email}}\\",\\"commented_by_username\\":\\"{{commentedBy_us' - 'ername}}\\",\\"commented_by_avatar\\":\\"{{commentedBy_avatar}}\\",\\"commented_b' - 'y_settings_discord_id\\":\\"{{commentedBy_settings_discordId}}\\",\\"commented_by' - '_settings_telegram_chat_id\\":\\"{{commentedBy_settings_telegramChatId}}\\"}}"' -) +JSON_PAYLOAD = """\ +{ + "notification_type": "{{notification_type}}", + "event": "{{event}}", + "subject": "{{subject}}", + "message": "{{message}}", + "image": "{{image}}", + "{{media}}": { + "media_type": "{{media_type}}", + "imdb_id": "{{media_imdbid}}", + "tmdb_id": "{{media_tmdbid}}", + "tvdb_id": "{{media_tvdbid}}", + "jellyfin_media_id": "{{media_jellyfinMediaId}}", + "status": "{{media_status}}", + "status4k": "{{media_status4k}}" + }, + "{{request}}": { + "request_id": "{{request_id}}", + "requested_by_email": "{{requestedBy_email}}", + "requested_by_username": "{{requestedBy_username}}", + "requested_by_avatar": "{{requestedBy_avatar}}", + "requested_by_jellyfin_user_id": "{{requestedBy_jellyfinUserId}}", + "requested_by_settings_discord_id": "{{requestedBy_settings_discordIds}}", + "requested_by_settings_telegram_chat_id": "{{requestedBy_settings_telegramChatId}}" + }, + "{{issue}}": { + "issue_id": "{{issue_id}}", + "issue_type": "{{issue_type}}", + "issue_status": "{{issue_status}}", + "reported_by_email": "{{reportedBy_email}}", + "reported_by_username": "{{reportedBy_username}}", + "reported_by_avatar": "{{reportedBy_avatar}}", + "reported_by_settings_discord_id": "{{reportedBy_settings_discordIds}}", + "reported_by_settings_telegram_chat_id": "{{reportedBy_settings_telegramChatId}}" + }, + "{{comment}}": { + "comment_message": "{{comment_message}}", + "commented_by_email": "{{commentedBy_email}}", + "commented_by_username": "{{commentedBy_username}}", + "commented_by_avatar": "{{commentedBy_avatar}}", + "commented_by_settings_discord_id": "{{commentedBy_settings_discordIds}}", + "commented_by_settings_telegram_chat_id": "{{commentedBy_settings_telegramChatId}}" + }, + "{{extra}}": [] +}""" diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index 65f21d414a807..969dd27e56aa1 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -146,7 +146,11 @@ async def determine_api_version( debugging. """ - hole_v6 = api_by_version(hass, entry, 6, password="wrong_password") + # Pi-hole v6 rate-limits login attempts, so probing with a deliberately + # wrong password makes the real login that follows shortly after fail as + # well. Probe with the configured password instead: a 401 identifies v6 + # just as well as a success does. + hole_v6 = api_by_version(hass, entry, 6) try: await hole_v6.authenticate() except HoleConnectionError as err: @@ -160,7 +164,8 @@ async def determine_api_version( except HoleError as ex_v6: if str(ex_v6) == "Authentication failed: Invalid password": _LOGGER.debug( - "Success connecting to Pi-hole at %s without auth, API version is : %s", + "Pi-hole at %s answered the v6 auth endpoint (password" + " rejected), API version is : %s", hole_v6.base_url, 6, ) @@ -169,13 +174,19 @@ async def determine_api_version( "Connection to %s failed: %s, trying API version 5", hole_v6.base_url, ex_v6 ) else: - # It seems that occasionally the auth can succeed - # unexpectedly when there is a valid session - _LOGGER.warning( - "Authenticated with %s through v6 API, but" - " succeeded with an incorrect password." - " This is a known bug", + # Release the probe session again: the operational client + # authenticates separately and Pi-hole allows only a limited + # number of concurrent sessions. + try: + await hole_v6.logout() + except HoleError as err: + _LOGGER.debug( + "Could not release the probe session at %s: %s", hole_v6.base_url, err + ) + _LOGGER.debug( + "Authenticated with %s through v6 API, API version is : %s", hole_v6.base_url, + 6, ) return 6 hole_v5 = api_by_version(hass, entry, 5, password="wrong_token") diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 3a2b144bcb400..0a14bfe193f54 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -27,9 +27,10 @@ PARALLEL_UPDATES = 0 -def _check_for_schedule(active: bool, last_active: str | None) -> None: +def _check_for_schedule(active: bool, last_active: str) -> None: """Raise a HAError when no thermostat schedule has been set.""" - if not active and last_active is None: + # last_active can be stored as None from before the plugwise v1.14.3 bump + if not active and last_active == STATE_OFF: raise HomeAssistantError( translation_domain=DOMAIN, translation_key=ERROR_NO_SCHEDULE, @@ -107,7 +108,7 @@ def __init__( self._api = coordinator.api gateway_id: str = self._api.gateway_id self._gateway_data = coordinator.data[gateway_id] - self._last_active_schedule: str | None = None + self._last_active_schedule = STATE_OFF self._location = device_id if (location := self.device.get("location")) is not None: self._location = location @@ -143,7 +144,9 @@ async def async_added_to_hass(self) -> None: plugwise_extra_data = PlugwiseClimateExtraStoredData.from_dict( extra_data.as_dict() ) - self._last_active_schedule = plugwise_extra_data.last_active_schedule + self._last_active_schedule = ( + plugwise_extra_data.last_active_schedule or STATE_OFF + ) self._previous_action_mode = ( plugwise_extra_data.previous_action_mode or HVACAction.HEATING.value ) @@ -209,7 +212,7 @@ def hvac_modes(self) -> list[HVACMode]: if "regulation_modes" in self._gateway_data: hvac_modes.append(HVACMode.OFF) - if self.device.get("available_schedules"): + if self.device["available_schedules"] != [STATE_OFF]: hvac_modes.append(HVACMode.AUTO) if self._api.cooling_present: @@ -291,8 +294,8 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: await self._api.set_regulation_mode(hvac_mode.value) return - current_schedule = self.device.get("select_schedule") - schedule_is_active = current_schedule not in (None, "off") + current_schedule = self.device["select_schedule"] + schedule_is_active = current_schedule != STATE_OFF desired_schedule = ( current_schedule if schedule_is_active else self._last_active_schedule ) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index a4d75c8bc4786..0ea06c62513a9 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["plugwise"], "quality_scale": "platinum", - "requirements": ["plugwise==1.14.2"], + "requirements": ["plugwise==1.14.4"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/proxmoxve/button.py b/homeassistant/components/proxmoxve/button.py index b93e455dccab4..339e2b3c3a36b 100644 --- a/homeassistant/components/proxmoxve/button.py +++ b/homeassistant/components/proxmoxve/button.py @@ -134,7 +134,7 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): key="hibernate", translation_key="hibernate", press_action=lambda coordinator, node, vmid: ( - coordinator.proxmox.nodes(node).qemu(vmid).status.hibernate.post() + coordinator.proxmox.nodes(node).qemu(vmid).status.suspend.post() ), entity_category=EntityCategory.CONFIG, ), diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index 904b88f894def..17ff5ebed3c99 100644 --- a/homeassistant/components/proxmoxve/strings.json +++ b/homeassistant/components/proxmoxve/strings.json @@ -137,7 +137,7 @@ }, "button": { "hibernate": { - "name": "Hibernate" + "name": "Suspend" }, "reset": { "name": "Reset" diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 8ea267cc500bb..fb2d4dd2ba742 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -188,6 +188,14 @@ class ReolinkHostButtonEntityDescription( supported=lambda api: api.supported(None, "reboot"), method=lambda api: api.reboot(), ), + ReolinkHostButtonEntityDescription( + key="sync_time", + translation_key="sync_time", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + supported=lambda api: api.supported(None, "sync_time"), + method=lambda api: api.baichuan.synchronize_time(), + ), ) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index efcf8aa7267d7..c5833e5c69c50 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -182,6 +182,9 @@ }, "ptz_zoom_out": { "default": "mdi:magnify" + }, + "sync_time": { + "default": "mdi:clock-outline" } }, "light": { @@ -378,6 +381,9 @@ } }, "select": { + "anti_flicker": { + "default": "mdi:camera-iris" + }, "auto_quick_reply_message": { "default": "mdi:message-reply-text-outline" }, diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 41bb8129d287c..e10c53be26f18 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -6,6 +6,7 @@ from typing import Any, override from reolink_aio.api import ( + AntiFlickerEnum, BinningModeEnum, Chime, ChimeToneEnum, @@ -217,6 +218,19 @@ def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int: value=lambda api, ch: ExposureEnum(api.exposure(ch)).name, method=lambda api, ch, name: api.set_exposure(ch, ExposureEnum[name].value), ), + ReolinkSelectEntityDescription( + key="anti_flicker", + cmd_key="GetIsp", + translation_key="anti_flicker", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + get_options=[method.name for method in AntiFlickerEnum], + supported=lambda api, ch: api.supported(ch, "anti_flicker"), + value=lambda api, ch: AntiFlickerEnum(api.anti_flicker_mode(ch)).name, + method=lambda api, ch, name: api.set_anti_flicker( + ch, AntiFlickerEnum[name].value + ), + ), ReolinkSelectEntityDescription( key="binning_mode", cmd_key="GetIsp", diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index c5fa83c37f39f..05ca7a3213d1f 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -244,6 +244,9 @@ }, "ptz_zoom_out": { "name": "PTZ zoom out" + }, + "sync_time": { + "name": "Synchronize time" } }, "camera": { @@ -481,6 +484,15 @@ } }, "select": { + "anti_flicker": { + "name": "Anti-flicker", + "state": { + "f50": "50 Hz", + "f60": "60 Hz", + "off": "[%key:common::state::off%]", + "other": "Other" + } + }, "auto_quick_reply_message": { "name": "Auto quick reply message", "state": { diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 62eb5b3f90bce..12abb7913c0a9 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -233,7 +233,7 @@ def __init__( # are treated as one device. if ( parent is not None - and parent != registry_device + and parent.device_id != registry_device.device_id and parent.device_type is not Device.Type.WallSwitch ): self._attr_device_info["via_device_id"] = ( diff --git a/homeassistant/components/twinkly/manifest.json b/homeassistant/components/twinkly/manifest.json index 78f3308e4010e..b2e61f55ef36e 100644 --- a/homeassistant/components/twinkly/manifest.json +++ b/homeassistant/components/twinkly/manifest.json @@ -15,5 +15,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["ttls"], - "requirements": ["ttls==1.8.3"] + "requirements": ["ttls==1.10.0"] } diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 46be0eac477ff..d05cb527546d7 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -19,6 +19,7 @@ ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_EXCLUDE, CONF_INCLUDE from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DATA_APPS @@ -31,6 +32,7 @@ CONF_NAME_SPACE, CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP, + DOMAIN, SUPPORTED_COMMANDS, VIZIO_AUDIO_SETTINGS, VIZIO_MUTE, @@ -351,12 +353,15 @@ def app_id(self) -> dict[str, Any] | None: # type: ignore[override] @override async def async_select_sound_mode(self, sound_mode: str) -> None: """Select sound mode.""" - if sound_mode in (self._attr_sound_mode_list or ()): - await async_device_command( - self._device.set_setting( - VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE, sound_mode - ) + if sound_mode not in (self._attr_sound_mode_list or ()): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_sound_mode", + translation_placeholders={"sound_mode": sound_mode}, ) + await async_device_command( + self._device.set_setting(VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE, sound_mode) + ) @override async def async_turn_on(self) -> None: @@ -407,6 +412,12 @@ async def async_select_source(self, source: str) -> None: ) elif source in self._available_apps: await async_device_command(self._device.launch_app(source)) + else: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_source", + translation_placeholders={"source": source}, + ) @override async def async_volume_up(self) -> None: diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 05f6403aef64a..f2092e440fa11 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -43,6 +43,12 @@ "command_error": { "message": "Failed to send command to the device: {error}" }, + "invalid_sound_mode": { + "message": "Sound mode \"{sound_mode}\" is not valid. Valid sound modes are listed in the sound_mode_list attribute." + }, + "invalid_source": { + "message": "Source \"{source}\" is not valid. Valid sources are listed in the source_list attribute." + }, "unknown_command": { "message": "Unknown remote command `{command}`. Valid commands for this device are listed in the integration documentation." }, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7e7719466d341..d8a658fc6dd61 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2065,6 +2065,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "fireavert": { + "name": "FireAvert", + "iot_standards": [ + "zwave" + ] + }, "firefly_iii": { "name": "Firefly III", "integration_type": "service", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index fee6d345e625f..7e99b05400f7f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==6.26.5 hass-nabucasa==2.2.0 hassil==3.11.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260729.4 +home-assistant-frontend==20260729.5 home-assistant-intents==2026.7.30 httpx==0.28.1 ifaddr==0.2.0 diff --git a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py index 5375d7b803676..caf6065965c94 100644 --- a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py +++ b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py @@ -5,7 +5,7 @@ from typing import Final -FRONTEND_VERSION: Final[str] = "20260729.4" +FRONTEND_VERSION: Final[str] = "20260729.5" MDI_ICONS: Final[set[str]] = { "ab-testing", diff --git a/requirements_all.txt b/requirements_all.txt index f3815c3036429..cb429a30ab2d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1281,7 +1281,7 @@ hole==0.9.2 holidays==0.101 # homeassistant.components.frontend -home-assistant-frontend==20260729.4 +home-assistant-frontend==20260729.5 # homeassistant.components.conversation home-assistant-intents==2026.7.30 @@ -1872,7 +1872,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.14.2 +plugwise==1.14.4 # homeassistant.components.serial_pm pmsensor==0.4 @@ -3235,7 +3235,7 @@ triggercmd==0.0.36 trmnl==0.1.1 # homeassistant.components.twinkly -ttls==1.8.3 +ttls==1.10.0 # homeassistant.components.thethingsnetwork ttn_client==1.3.0 diff --git a/tests/components/button/test_trigger.py b/tests/components/button/test_trigger.py index 3b141ce4ffbb7..4b0634bd39acf 100644 --- a/tests/components/button/test_trigger.py +++ b/tests/components/button/test_trigger.py @@ -18,9 +18,11 @@ @pytest.fixture -async def target_buttons(hass: HomeAssistant) -> dict[str, list[str]]: - """Create multiple button entities associated with different targets.""" - return await target_entities(hass, "button") +async def target_entities_indirect( + request: pytest.FixtureRequest, hass: HomeAssistant +) -> dict[str, list[str]]: + """Create multiple entities associated with different targets.""" + return await target_entities(hass, request.param) @pytest.mark.parametrize( @@ -47,8 +49,25 @@ async def test_button_trigger_options_validation( @pytest.mark.parametrize( - ("trigger_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("button"), + ( + "target_entities_indirect", + "trigger_target_config", + "entity_id", + "entities_in_target", + ), + [ + *[ + ("button", config, entity_id, entities) + for (config, entity_id, entities) in parametrize_target_entities("button") + ], + *[ + ("input_button", config, entity_id, entities) + for (config, entity_id, entities) in parametrize_target_entities( + "input_button" + ) + ], + ], + indirect=["target_entities_indirect"], ) @pytest.mark.parametrize( ("trigger", "states"), @@ -163,7 +182,7 @@ async def test_button_trigger_options_validation( ) async def test_button_state_trigger( hass: HomeAssistant, - target_buttons: dict[str, list[str]], + target_entities_indirect: dict[str, list[str]], trigger_target_config: dict, entity_id: str, entities_in_target: int, @@ -172,10 +191,10 @@ async def test_button_state_trigger( ) -> None: """Test that the button state trigger fires when targeted button state changes.""" calls: list[str] = [] - other_entity_ids = set(target_buttons["included_entities"]) - {entity_id} + other_entity_ids = set(target_entities_indirect["included_entities"]) - {entity_id} # Set all buttons, including the tested button, to the initial state - for eid in target_buttons["included_entities"]: + for eid in target_entities_indirect["included_entities"]: set_or_remove_state(hass, eid, states[0]["included_state"]) await hass.async_block_till_done() diff --git a/tests/components/fritz/test_coordinator.py b/tests/components/fritz/test_coordinator.py index 01a7b85648798..5f362b0a5d892 100644 --- a/tests/components/fritz/test_coordinator.py +++ b/tests/components/fritz/test_coordinator.py @@ -16,6 +16,7 @@ from homeassistant.components.fritz.const import ( CONF_FEATURE_DEVICE_TRACKING, + CONF_OLD_DISCOVERY, DEFAULT_CONF_FEATURE_DEVICE_TRACKING, DEFAULT_SSL, DOMAIN, @@ -695,3 +696,41 @@ async def test_async_trigger_cleanup_preserves_fritz_device( ) assert fritz_device_after is not None assert fritz_device_after.id == fritz_device.id + + +async def test_old_discovery_does_not_self_reference_box( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + fc_class_mock, + fh_class_mock, + fs_class_mock, +) -> None: + """Test old discovery does not link the box as its own via device. + + The Hosts table lists the box's own LAN MAC (MOCK_HOST_FRITZBOX). Its MAC + connection matches the router device, so tracking it would merge it into the + router and resolve via_device_id to itself, which the device registry rejects. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_USER_DATA, + options={CONF_OLD_DISCOVERY: True}, + ) + entry.add_to_hass(hass) + + assert 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 + + router = device_registry.async_get_device( + identifiers={(DOMAIN, MOCK_SERIAL_NUMBER)} + ) + assert router is not None + assert router.via_device_id is None + + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + assert devices + for device in devices: + assert device.via_device_id != device.id diff --git a/tests/components/govee_ble/__init__.py b/tests/components/govee_ble/__init__.py index d3608385b137a..b494256e6ea32 100644 --- a/tests/components/govee_ble/__init__.py +++ b/tests/components/govee_ble/__init__.py @@ -24,6 +24,31 @@ source="local", ) +GVH5184_SERVICE_INFO = BluetoothServiceInfo( + name="GVH5184_XXXX", + address="4125DDBA-2774-4851-9889-6AADDD4CAC3D", + rssi=-56, + manufacturer_data={ + 6966: b" \x01\x00\x01\x01\xe4\x01\x86\x0c\x1c\xff\xff\x86\n\xf0\xff\xff", + 76: b"\x02\x15INTELLI_ROCKS_HWPu\xf2\xff\x0c", + }, + service_uuids=["00008451-0000-1000-8000-00805f9b34fb"], + service_data={}, + source="local", +) + +GVH5198_SERVICE_INFO = BluetoothServiceInfo( + name="", + address="4125DDBA-2774-4851-9889-6AADDD4CAC3D", + rssi=-56, + manufacturer_data={ + 12322: b"\x6c\x01\x00\x01\x01\xa4\xc2\x0f\x0e\x10\xff\xff\xff\xff\x08\xfc\xff\xff\xff\xff" + }, + service_uuids=["00009851-0000-1000-8000-00805f9b34fb"], + service_data={}, + source="local", +) + GVH5177_SERVICE_INFO = BluetoothServiceInfo( name="GVH5177 2EC8", address="4125DDBA-2774-4851-9889-6AADDD4CAC3D", diff --git a/tests/components/govee_ble/test_sensor.py b/tests/components/govee_ble/test_sensor.py index 85235e756104a..9a34545efdc78 100644 --- a/tests/components/govee_ble/test_sensor.py +++ b/tests/components/govee_ble/test_sensor.py @@ -3,6 +3,8 @@ from datetime import timedelta import time +import pytest + from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, ) @@ -14,6 +16,7 @@ STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from homeassistant.util import dt as dt_util from . import ( @@ -23,6 +26,8 @@ GVH5178_PRIMARY_SERVICE_INFO, GVH5178_REMOTE_SERVICE_INFO, GVH5178_SERVICE_INFO_ERROR, + GVH5184_SERVICE_INFO, + GVH5198_SERVICE_INFO, ) from tests.common import MockConfigEntry, async_fire_time_changed @@ -197,7 +202,7 @@ async def test_gv5140(hass: HomeAssistant) -> None: co2_sensor = hass.states.get("sensor.5140eeff_carbon_dioxide") co2_sensor_attributes = co2_sensor.attributes assert co2_sensor.state == "531" - assert co2_sensor_attributes[ATTR_FRIENDLY_NAME] == "5140EEFF Carbon Dioxide" + assert co2_sensor_attributes[ATTR_FRIENDLY_NAME] == "5140EEFF Carbon dioxide" assert co2_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "ppm" assert co2_sensor_attributes[ATTR_STATE_CLASS] == "measurement" @@ -205,6 +210,102 @@ async def test_gv5140(hass: HomeAssistant) -> None: await hass.async_block_till_done() +@pytest.mark.parametrize( + ("service_info", "expected_sensors"), + [ + pytest.param( + GVH5184_SERVICE_INFO, + [ + ( + "sensor.h5184_ac3d_temperature_probe_1", + "31.0", + "H5184 AC3D Temperature probe 1", + ), + ( + "sensor.h5184_ac3d_temperature_alarm_probe_1", + "0.0", + "H5184 AC3D Temperature alarm probe 1", + ), + ( + "sensor.h5184_ac3d_temperature_probe_2", + "28.0", + "H5184 AC3D Temperature probe 2", + ), + ( + "sensor.h5184_ac3d_temperature_alarm_probe_2", + "0.0", + "H5184 AC3D Temperature alarm probe 2", + ), + ], + id="h5184", + ), + pytest.param( + GVH5198_SERVICE_INFO, + [ + ( + "sensor.h5198_ac3d_temperature_probe_3", + "36.0", + "H5198 AC3D Temperature probe 3", + ), + ( + "sensor.h5198_ac3d_temperature_alarm_probe_3", + "0.0", + "H5198 AC3D Temperature alarm probe 3", + ), + ( + "sensor.h5198_ac3d_low_temperature_alarm_probe_3", + "0.0", + "H5198 AC3D Low temperature alarm probe 3", + ), + ( + "sensor.h5198_ac3d_temperature_probe_4", + "23.0", + "H5198 AC3D Temperature probe 4", + ), + ( + "sensor.h5198_ac3d_temperature_alarm_probe_4", + "0.0", + "H5198 AC3D Temperature alarm probe 4", + ), + ( + "sensor.h5198_ac3d_low_temperature_alarm_probe_4", + "0.0", + "H5198 AC3D Low temperature alarm probe 4", + ), + ], + id="h5198", + ), + ], +) +async def test_probe_sensors( + hass: HomeAssistant, + service_info: BluetoothServiceInfo, + expected_sensors: list[tuple[str, str, str]], +) -> None: + """Test grill thermometer probe sensors.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="4125DDBA-2774-4851-9889-6AADDD4CAC3D", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + inject_bluetooth_service_info(hass, service_info) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == len(expected_sensors) + + for entity_id, state, friendly_name in expected_sensors: + sensor = hass.states.get(entity_id) + assert sensor.state == state + assert sensor.attributes[ATTR_FRIENDLY_NAME] == friendly_name + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + async def test_gvh5106(hass: HomeAssistant) -> None: """Test setting up creates the sensors for a device with PM25.""" entry = MockConfigEntry( @@ -221,10 +322,10 @@ async def test_gvh5106(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(hass.states.async_all()) == 3 - pm25_sensor = hass.states.get("sensor.h5106_4e05_pm25") + pm25_sensor = hass.states.get("sensor.h5106_4e05_pm2_5") pm25_sensor_attributes = pm25_sensor.attributes assert pm25_sensor.state == "0" - assert pm25_sensor_attributes[ATTR_FRIENDLY_NAME] == "H5106 4E05 Pm25" + assert pm25_sensor_attributes[ATTR_FRIENDLY_NAME] == "H5106 4E05 PM2.5" assert pm25_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "μg/m³" assert pm25_sensor_attributes[ATTR_STATE_CLASS] == "measurement" diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index 06d9ff8474fd6..3ca3bdf764bfd 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -1271,6 +1271,38 @@ async def test_entity_device_info_with_identifier( assert device.sw_version == "0.1-beta" +async def test_entity_device_info_with_via_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test device_trigger device registry integration links via_device_id.""" + await mqtt_mock_entry() + mqtt_config_entry = hass.config_entries.async_entries(DOMAIN)[0] + hub = device_registry.async_get_or_create( + config_entry_id=mqtt_config_entry.entry_id, + identifiers={("mqtt", "hub-id")}, + manufacturer="manufacturer", + model="hub", + ) + + data = json.dumps( + { + "automation_type": "trigger", + "topic": "test-topic", + "type": "foo", + "subtype": "bar", + "device": {"identifiers": ["helloworld"], "via_device": "hub-id"}, + } + ) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) + assert device is not None + assert device.via_device_id == hub.id + + async def test_entity_device_info_update( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index b8241c860b797..2e6c1d3a48202 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -51,7 +51,11 @@ from homeassistant.setup import async_setup_component from homeassistant.util.signal_type import SignalTypeFormat -from .common import help_all_subscribe_calls, help_test_unload_config_entry +from .common import ( + MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + help_all_subscribe_calls, + help_test_unload_config_entry, +) from .conftest import ENTRY_DEFAULT_BIRTH_MESSAGE from .test_tag import DEFAULT_TAG_ID, DEFAULT_TAG_SCAN @@ -3246,6 +3250,12 @@ async def test_discovery_with_late_via_device_discovery( await hass.async_block_till_done() + # The child device links to the stub via device by via_device_id + stub_id = via_device_entry.id + child_device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + assert child_device_entry is not None + assert child_device_entry.via_device_id == stub_id + # Now discover the via device (a switch) via_device_config = { "name": None, @@ -3267,6 +3277,12 @@ async def test_discovery_with_late_via_device_discovery( ) assert via_device_entry is not None assert via_device_entry.name == "My Switch" + # The stub merges into the announced device, keeping its id, so the link + # from the child device survives + assert via_device_entry.id == stub_id + child_device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + assert child_device_entry is not None + assert child_device_entry.via_device_id == stub_id await help_check_discovered_items(hass, device_registry, tag_mock) @@ -3322,6 +3338,12 @@ async def test_discovery_with_late_via_device_update( await hass.async_block_till_done() await hass.async_block_till_done() + # The discovery update established the via_device_id link on the child device + stub_id = via_device_entry.id + child_device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + assert child_device_entry is not None + assert child_device_entry.via_device_id == stub_id + # Now discover the via device (a switch) via_device_config = { "name": None, @@ -3343,10 +3365,117 @@ async def test_discovery_with_late_via_device_update( ) assert via_device_entry is not None assert via_device_entry.name == "My Switch" + assert via_device_entry.id == stub_id + child_device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + assert child_device_entry is not None + assert child_device_entry.via_device_id == stub_id await help_check_discovered_items(hass, device_registry, tag_mock) +async def test_via_device_relinks_after_parent_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test a child re-links to its via device after the parent is removed.""" + await mqtt_mock_entry() + + parent_config = { + "name": "Parent", + "command_topic": "test-parent-topic", + "unique_id": "parent_unique", + "device": {"identifiers": ["parent-id"], "name": "Parent"}, + } + async_fire_mqtt_message( + hass, "homeassistant/switch/parent/config", json.dumps(parent_config) + ) + child_config = { + "name": "Child", + "state_topic": "test-child-topic", + "event_types": ["press"], + "unique_id": "child_unique", + "device": {"identifiers": ["child-id"], "via_device": "parent-id"}, + } + async_fire_mqtt_message( + hass, "homeassistant/event/child/config", json.dumps(child_config) + ) + await hass.async_block_till_done() + + parent = device_registry.async_get_device({("mqtt", "parent-id")}) + child = device_registry.async_get_device({("mqtt", "child-id")}) + assert parent is not None + assert child is not None + assert child.via_device_id == parent.id + + # Removing the parent clears the child's via_device_id + device_registry.async_remove_device(parent.id) + await hass.async_block_till_done() + child = device_registry.async_get_device({("mqtt", "child-id")}) + assert child is not None + assert child.via_device_id is None + + # A child discovery update re-creates the via device stub and re-links + child_config["name"] = "Child updated" + async_fire_mqtt_message( + hass, "homeassistant/event/child/config", json.dumps(child_config) + ) + await hass.async_block_till_done() + + parent_stub = device_registry.async_get_device({("mqtt", "parent-id")}) + child = device_registry.async_get_device({("mqtt", "child-id")}) + assert parent_stub is not None + assert child is not None + assert child.via_device_id == parent_stub.id + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + config_entries.ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + subentry_type="device", + title="Parent subentry device", + ), + ) + ], +) +async def test_via_device_across_subentries( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test via_device resolves across subentries within one config entry.""" + await mqtt_mock_entry() + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + subentry_id = next(iter(config_entry.subentries)) + parent = device_registry.async_get_device({(DOMAIN, subentry_id)}) + assert parent is not None + assert parent.config_subentry_id == subentry_id + + child_config = { + "name": "Child", + "state_topic": "test-child-topic", + "event_types": ["press"], + "unique_id": "child_unique", + "device": {"identifiers": ["child-id"], "via_device": subentry_id}, + } + async_fire_mqtt_message( + hass, "homeassistant/event/child/config", json.dumps(child_config) + ) + await hass.async_block_till_done() + + child = device_registry.async_get_device({(DOMAIN, "child-id")}) + assert child is not None + # The parent lives in a subentry and the discovered child does not, yet the + # link resolves because lookups are scoped to the config entry. + assert child.config_subentry_id is None + assert child.config_entry_id == parent.config_entry_id + assert child.via_device_id == parent.id + + async def test_shared_options_in_sync_with_device_schema() -> None: """Test shared options in device discovery schema are in sync. diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index f5f4e52ce4885..bd317d1c8c2d2 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -506,6 +506,35 @@ async def test_entity_device_info_with_identifier( assert device.sw_version == "0.1-beta" +async def test_entity_device_info_with_via_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test tag device registry integration links via_device_id.""" + await mqtt_mock_entry() + mqtt_config_entry = hass.config_entries.async_entries(DOMAIN)[0] + hub = device_registry.async_get_or_create( + config_entry_id=mqtt_config_entry.entry_id, + identifiers={("mqtt", "hub-id")}, + manufacturer="manufacturer", + model="hub", + ) + + data = json.dumps( + { + "topic": "test-topic", + "device": {"identifiers": ["helloworld"], "via_device": "hub-id"}, + } + ) + async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", data) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) + assert device is not None + assert device.via_device_id == hub.id + + async def test_entity_device_info_update( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/nintendo_parental_controls/test_services.py b/tests/components/nintendo_parental_controls/test_services.py index 024e671e1d02e..cf68d9f4c9f5f 100644 --- a/tests/components/nintendo_parental_controls/test_services.py +++ b/tests/components/nintendo_parental_controls/test_services.py @@ -13,13 +13,13 @@ NintendoParentalServices, ) from homeassistant.const import ATTR_DEVICE_ID, CONF_PIN -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import ServiceValidationError, Unauthorized from homeassistant.helpers import device_registry as dr from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, MockUser async def test_add_bonus_time( @@ -163,3 +163,31 @@ async def test_update_pin_code( blocking=True, ) assert len(mock_nintendo_device.set_new_pin.mock_calls) == 1 + + +async def test_update_pin_code_requires_admin( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + hass_read_only_user: MockUser, + mock_config_entry: MockConfigEntry, + mock_nintendo_client: AsyncMock, + mock_nintendo_device: AsyncMock, +) -> None: + """Test updating the PIN code requires administrator access.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, "testdevid"), mock_config_entry.entry_id + ) + assert device_entry + with pytest.raises(Unauthorized): + await hass.services.async_call( + DOMAIN, + NintendoParentalServices.UPDATE_PIN_CODE, + { + ATTR_DEVICE_ID: device_entry.id, + CONF_PIN: "1234", + }, + blocking=True, + context=Context(user_id=hass_read_only_user.id), + ) + mock_nintendo_device.set_new_pin.assert_not_called() diff --git a/tests/components/overseerr/fixtures/webhook_config.json b/tests/components/overseerr/fixtures/webhook_config.json index 48619a41ba721..38c01e703208c 100644 --- a/tests/components/overseerr/fixtures/webhook_config.json +++ b/tests/components/overseerr/fixtures/webhook_config.json @@ -2,7 +2,7 @@ "enabled": true, "types": 4062, "options": { - "jsonPayload": "{\"notification_type\":\"{{notification_type}}\",\"subject\":\"{{subject}}\",\"message\":\"{{message}}\",\"image\":\"{{image}}\",\"{{media}}\":{\"media_type\":\"{{media_type}}\",\"tmdb_id\":\"{{media_tmdbid}}\",\"tvdb_id\":\"{{media_tvdbid}}\",\"status\":\"{{media_status}}\",\"status4k\":\"{{media_status4k}}\"},\"{{request}}\":{\"request_id\":\"{{request_id}}\",\"requested_by_email\":\"{{requestedBy_email}}\",\"requested_by_username\":\"{{requestedBy_username}}\",\"requested_by_avatar\":\"{{requestedBy_avatar}}\",\"requested_by_settings_discord_id\":\"{{requestedBy_settings_discordId}}\",\"requested_by_settings_telegram_chat_id\":\"{{requestedBy_settings_telegramChatId}}\"},\"{{issue}}\":{\"issue_id\":\"{{issue_id}}\",\"issue_type\":\"{{issue_type}}\",\"issue_status\":\"{{issue_status}}\",\"reported_by_email\":\"{{reportedBy_email}}\",\"reported_by_username\":\"{{reportedBy_username}}\",\"reported_by_avatar\":\"{{reportedBy_avatar}}\",\"reported_by_settings_discord_id\":\"{{reportedBy_settings_discordId}}\",\"reported_by_settings_telegram_chat_id\":\"{{reportedBy_settings_telegramChatId}}\"},\"{{comment}}\":{\"comment_message\":\"{{comment_message}}\",\"commented_by_email\":\"{{commentedBy_email}}\",\"commented_by_username\":\"{{commentedBy_username}}\",\"commented_by_avatar\":\"{{commentedBy_avatar}}\",\"commented_by_settings_discord_id\":\"{{commentedBy_settings_discordId}}\",\"commented_by_settings_telegram_chat_id\":\"{{commentedBy_settings_telegramChatId}}\"}}", + "jsonPayload": "{\n \"notification_type\": \"{{notification_type}}\",\n \"event\": \"{{event}}\",\n \"subject\": \"{{subject}}\",\n \"message\": \"{{message}}\",\n \"image\": \"{{image}}\",\n \"{{media}}\": {\n \"media_type\": \"{{media_type}}\",\n \"imdb_id\": \"{{media_imdbid}}\",\n \"tmdb_id\": \"{{media_tmdbid}}\",\n \"tvdb_id\": \"{{media_tvdbid}}\",\n \"jellyfin_media_id\": \"{{media_jellyfinMediaId}}\",\n \"status\": \"{{media_status}}\",\n \"status4k\": \"{{media_status4k}}\"\n },\n \"{{request}}\": {\n \"request_id\": \"{{request_id}}\",\n \"requested_by_email\": \"{{requestedBy_email}}\",\n \"requested_by_username\": \"{{requestedBy_username}}\",\n \"requested_by_avatar\": \"{{requestedBy_avatar}}\",\n \"requested_by_jellyfin_user_id\": \"{{requestedBy_jellyfinUserId}}\",\n \"requested_by_settings_discord_id\": \"{{requestedBy_settings_discordIds}}\",\n \"requested_by_settings_telegram_chat_id\": \"{{requestedBy_settings_telegramChatId}}\"\n },\n \"{{issue}}\": {\n \"issue_id\": \"{{issue_id}}\",\n \"issue_type\": \"{{issue_type}}\",\n \"issue_status\": \"{{issue_status}}\",\n \"reported_by_email\": \"{{reportedBy_email}}\",\n \"reported_by_username\": \"{{reportedBy_username}}\",\n \"reported_by_avatar\": \"{{reportedBy_avatar}}\",\n \"reported_by_settings_discord_id\": \"{{reportedBy_settings_discordIds}}\",\n \"reported_by_settings_telegram_chat_id\": \"{{reportedBy_settings_telegramChatId}}\"\n },\n \"{{comment}}\": {\n \"comment_message\": \"{{comment_message}}\",\n \"commented_by_email\": \"{{commentedBy_email}}\",\n \"commented_by_username\": \"{{commentedBy_username}}\",\n \"commented_by_avatar\": \"{{commentedBy_avatar}}\",\n \"commented_by_settings_discord_id\": \"{{commentedBy_settings_discordIds}}\",\n \"commented_by_settings_telegram_chat_id\": \"{{commentedBy_settings_telegramChatId}}\"\n },\n \"{{extra}}\": []\n}", "webhookUrl": "http://10.10.10.10:8123/api/webhook/test-webhook-id" } } diff --git a/tests/components/overseerr/fixtures/webhook_issue_reported.json b/tests/components/overseerr/fixtures/webhook_issue_reported.json index 425e27529424f..727e5f8e94e95 100644 --- a/tests/components/overseerr/fixtures/webhook_issue_reported.json +++ b/tests/components/overseerr/fixtures/webhook_issue_reported.json @@ -1,15 +1,19 @@ { "notification_type": "ISSUE_REPORTED", + "event": "Issue was reported", "subject": "New Issue Reported", "message": "A new video issue has been reported for Interstellar", "image": "https://image.tmdb.org/t/p/w600_and_h900_bestv2/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg", "media": { "media_type": "movie", + "imdb_id": "tt1234567", "tmdb_id": "157336", "tvdb_id": "", + "jellyfin_media_id": "", "status": "available", "status4k": "unknown" }, + "request": null, "issue": { "issue_id": "1", "issue_type": "video", @@ -19,5 +23,7 @@ "reported_by_avatar": "/os_logo_square.png", "reported_by_settings_discord_id": "", "reported_by_settings_telegram_chat_id": "" - } + }, + "comment": null, + "extra": [] } diff --git a/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json b/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json index 75059bcaf9664..9a3685a72b3f2 100644 --- a/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json +++ b/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json @@ -1,12 +1,15 @@ { "notification_type": "MEDIA_AUTO_APPROVED", + "event": "Request was auto-approved", "subject": "Something (2024)", "message": "Here is an interesting Linux ISO that was automatically approved.", "image": "https://image.tmdb.org/t/p/w600_and_h900_bestv2/something.jpg", "media": { "media_type": "movie", + "imdb_id": "tt1234567", "tmdb_id": "123", "tvdb_id": "", + "jellyfin_media_id": "", "status": "PENDING", "status4k": "UNKNOWN" }, @@ -15,9 +18,11 @@ "requested_by_email": "my@email.com", "requested_by_username": "henk", "requested_by_avatar": "https://plex.tv/users/abc/avatar?c=123", + "requested_by_jellyfin_user_id": "", "requested_by_settings_discord_id": "123", "requested_by_settings_telegram_chat_id": "" }, "issue": null, - "comment": null + "comment": null, + "extra": [] } diff --git a/tests/components/overseerr/snapshots/test_event.ambr b/tests/components/overseerr/snapshots/test_event.ambr index f11e8d17d675c..d9d01fbaab837 100644 --- a/tests/components/overseerr/snapshots/test_event.ambr +++ b/tests/components/overseerr/snapshots/test_event.ambr @@ -49,6 +49,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'https://image.tmdb.org/t/p/w600_and_h900_bestv2/something.jpg', + 'event': 'Request was auto-approved', : 'auto_approved', : list([ 'pending', @@ -58,8 +59,12 @@ 'declined', 'auto_approved', ]), + 'extra': list([ + ]), : 'Overseerr Last media event', 'media': dict({ + 'imdb_id': 'tt1234567', + 'jellyfin_media_id': '', 'media_type': 'movie', 'status': 'pending', 'status4k': 'unknown', @@ -71,6 +76,7 @@ 'request_id': 16, 'requested_by_avatar': 'https://plex.tv/users/abc/avatar?c=123', 'requested_by_email': 'my@email.com', + 'requested_by_jellyfin_user_id': '', 'requested_by_settings_discord_id': '123', 'requested_by_settings_telegram_chat_id': '', 'requested_by_username': 'henk', diff --git a/tests/components/overseerr/test_config_flow.py b/tests/components/overseerr/test_config_flow.py index 7f3b3150b3586..e0108b1ecb665 100644 --- a/tests/components/overseerr/test_config_flow.py +++ b/tests/components/overseerr/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for the Overseerr config flow.""" +from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest @@ -27,7 +28,7 @@ @pytest.fixture(autouse=True) -def patch_webhook_id() -> None: +def patch_webhook_id() -> Generator[None]: """Patch webhook ID generation.""" with patch( "homeassistant.components.overseerr.config_flow.async_generate_id", diff --git a/tests/components/overseerr/test_init.py b/tests/components/overseerr/test_init.py index 5a63df170aae6..0b3021ab96136 100644 --- a/tests/components/overseerr/test_init.py +++ b/tests/components/overseerr/test_init.py @@ -1,5 +1,6 @@ """Tests for the Overseerr integration.""" +import json from typing import Any from unittest.mock import AsyncMock, patch @@ -91,7 +92,7 @@ async def test_proper_webhook_configuration( { "return_value.options": WebhookNotificationOptions( webhook_url="http://10.10.10.10:8123/api/webhook/test-webhook-id", - json_payload='"{\\"message\\": \\"{{title}}\\"}"', + json_payload='{"message": "{{title}}"}', ) }, ], @@ -130,7 +131,7 @@ async def test_webhook_configuration_need_update( { "return_value.options": WebhookNotificationOptions( webhook_url="http://10.10.10.10:8123/api/webhook/test-webhook-id", - json_payload='"{\\"message\\": \\"{{title}}\\"}"', + json_payload='{"message": "{{title}}"}', ) }, ], @@ -157,6 +158,12 @@ async def test_webhook_failing_test( mock_overseerr_client.set_webhook_notification_config.assert_not_called() +async def test_webhook_json_payload_formatting() -> None: + """Test the webhook JSON payload formatting.""" + + assert json.loads(JSON_PAYLOAD)["notification_type"] == "{{notification_type}}" + + async def test_prefer_internal_ip( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/pi_hole/__init__.py b/tests/components/pi_hole/__init__.py index 6f6df578546f6..46c854a8aa432 100644 --- a/tests/components/pi_hole/__init__.py +++ b/tests/components/pi_hole/__init__.py @@ -202,6 +202,7 @@ def _create_mocked_hole( incorrect_app_password: bool = False, wrong_host: bool = False, ftl_error: bool = False, + logout_error: bool = False, ) -> MagicMock: """Return a mocked Hole API object with side effects based on constructor args.""" @@ -250,6 +251,9 @@ async def ftl_side_effect(): mocked_hole.data = FTL_ERROR mocked_hole.authenticate = AsyncMock(side_effect=authenticate_side_effect) + mocked_hole.logout = AsyncMock( + side_effect=HoleError("Logout failed") if logout_error else None + ) mocked_hole.get_data = AsyncMock(side_effect=get_data_side_effect) if ftl_error: diff --git a/tests/components/pi_hole/test_init.py b/tests/components/pi_hole/test_init.py index 526b54b60f936..827a5e17c0143 100644 --- a/tests/components/pi_hole/test_init.py +++ b/tests/components/pi_hole/test_init.py @@ -62,6 +62,41 @@ async def test_setup_api_v6( ) +async def test_version_probe_uses_configured_password(hass: HomeAssistant) -> None: + """Test the v6 version probe never authenticates with a sentinel password. + + Pi-hole v6 rate-limits login attempts, so a deliberately failed probe makes + the real login that follows shortly after fail as well, and the config entry + then never recovers on its own. + """ + mocked_hole = _create_mocked_hole(api_version=6) + entry = MockConfigEntry(domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS}) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole): + assert await hass.config_entries.async_setup(entry.entry_id) + + assert mocked_hole.instances + assert all(instance.password == API_KEY for instance in mocked_hole.instances) + # The probe releases its session again so it does not use up one of the + # limited number of Pi-hole sessions. + mocked_hole.instances[0].logout.assert_awaited_once() + + +async def test_version_probe_survives_failing_logout(hass: HomeAssistant) -> None: + """Test setup still succeeds when releasing the probe session fails. + + Releasing the session is a courtesy, not a requirement, so a failure there + must not turn a working configuration into a failed setup. + """ + mocked_hole = _create_mocked_hole(api_version=6, logout_error=True) + entry = MockConfigEntry(domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS}) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole): + assert await hass.config_entries.async_setup(entry.entry_id) + + assert entry.state is ConfigEntryState.LOADED + + @pytest.mark.parametrize( ("config_entry_data", "expected_api_token"), [({**CONFIG_DATA_DEFAULTS}, API_KEY)], diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/data.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/data.json index 06e8b00605b00..c90e949bfcf8b 100644 --- a/tests/components/plugwise/fixtures/anna_heatpump_heating/data.json +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/data.json @@ -64,7 +64,7 @@ }, "3cb70739631c4d17a86b8b12e8a5161b": { "active_preset": "home", - "available_schedules": ["standaard", "off"], + "available_schedules": ["off", "standaard"], "climate_mode": "auto", "control_state": "heating", "dev_class": "thermostat", diff --git a/tests/components/plugwise/fixtures/anna_loria_cooling_active/data.json b/tests/components/plugwise/fixtures/anna_loria_cooling_active/data.json index abd86ffbd8529..b4c3092dddaee 100644 --- a/tests/components/plugwise/fixtures/anna_loria_cooling_active/data.json +++ b/tests/components/plugwise/fixtures/anna_loria_cooling_active/data.json @@ -1,7 +1,7 @@ { "582dfbdace4d4aeb832923ce7d1ddda0": { "active_preset": "home", - "available_schedules": ["Winter", "Test ", "off"], + "available_schedules": ["off", "Winter", "Test "], "climate_mode": "auto", "control_state": "cooling", "dev_class": "thermostat", diff --git a/tests/components/plugwise/fixtures/anna_p1/data.json b/tests/components/plugwise/fixtures/anna_p1/data.json index 5191801b6dbfb..b24c11912c2eb 100644 --- a/tests/components/plugwise/fixtures/anna_p1/data.json +++ b/tests/components/plugwise/fixtures/anna_p1/data.json @@ -1,7 +1,7 @@ { "1e5e55b958ac445583602f767cb45942": { "active_preset": "home", - "available_schedules": ["Thermostat schedule", "off"], + "available_schedules": ["off", "Thermostat schedule"], "climate_mode": "heat", "control_state": "idle", "dev_class": "thermostat", diff --git a/tests/components/plugwise/fixtures/legacy_anna/data.json b/tests/components/plugwise/fixtures/legacy_anna/data.json index e08e79b60864d..52b644ed4dde5 100644 --- a/tests/components/plugwise/fixtures/legacy_anna/data.json +++ b/tests/components/plugwise/fixtures/legacy_anna/data.json @@ -36,7 +36,7 @@ }, "0d266432d64443e283b5d708ae98b455": { "active_preset": "home", - "available_schedules": [], + "available_schedules": ["off"], "climate_mode": "heat", "control_state": "heating", "dev_class": "thermostat", @@ -46,7 +46,7 @@ "model": "ThermoTouch", "name": "Anna", "preset_modes": ["away", "vacation", "asleep", "home", "no_frost"], - "select_schedule": null, + "select_schedule": "off", "sensors": { "illuminance": 150.8, "setpoint": 20.5, diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/data.json b/tests/components/plugwise/fixtures/m_adam_cooling/data.json index 3031855b91782..6c27782b1c8e4 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/data.json @@ -206,11 +206,11 @@ "f2bf9048bef64cc5b6d5110154e33c81": { "active_preset": "home", "available_schedules": [ + "off", "Badkamer", "Vakantie", "Weekschema", - "Test", - "off" + "Test" ], "climate_mode": "cool", "control_state": "cooling", @@ -245,11 +245,11 @@ "f871b8c4d63549319221e294e4f88074": { "active_preset": "vacation", "available_schedules": [ + "off", "Badkamer", "Vakantie", "Weekschema", - "Test", - "off" + "Test" ], "climate_mode": "auto", "control_state": "cooling", diff --git a/tests/components/plugwise/fixtures/m_adam_heating/data.json b/tests/components/plugwise/fixtures/m_adam_heating/data.json index 0086b5fbb9dbd..46accd82ffc0b 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/data.json @@ -206,11 +206,11 @@ "f2bf9048bef64cc5b6d5110154e33c81": { "active_preset": "home", "available_schedules": [ + "off", "Badkamer", "Vakantie", "Weekschema", - "Test", - "off" + "Test" ], "climate_mode": "heat", "control_state": "preheating", @@ -245,11 +245,11 @@ "f871b8c4d63549319221e294e4f88074": { "active_preset": "vacation", "available_schedules": [ + "off", "Badkamer", "Vakantie", "Weekschema", - "Test", - "off" + "Test" ], "climate_mode": "auto", "control_state": "idle", diff --git a/tests/components/plugwise/fixtures/m_adam_heating_off_schedule/data.json b/tests/components/plugwise/fixtures/m_adam_heating_off_schedule/data.json index cd74e546e4b49..d52de28f75cb6 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating_off_schedule/data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating_off_schedule/data.json @@ -206,11 +206,11 @@ "f2bf9048bef64cc5b6d5110154e33c81": { "active_preset": "home", "available_schedules": [ + "off", "Badkamer", "Vakantie", "Weekschema", - "Test", - "off" + "Test" ], "climate_mode": "off", "control_state": "idle", @@ -245,11 +245,11 @@ "f871b8c4d63549319221e294e4f88074": { "active_preset": "vacation", "available_schedules": [ + "off", "Badkamer", "Vakantie", "Weekschema", - "Test", - "off" + "Test" ], "climate_mode": "off", "control_state": "idle", diff --git a/tests/components/plugwise/fixtures/m_adam_jip/data.json b/tests/components/plugwise/fixtures/m_adam_jip/data.json index eb359bb07548c..c61fd262c543b 100644 --- a/tests/components/plugwise/fixtures/m_adam_jip/data.json +++ b/tests/components/plugwise/fixtures/m_adam_jip/data.json @@ -1,13 +1,13 @@ { "06aecb3d00354375924f50c47af36bd2": { "active_preset": "no_frost", - "available_schedules": [], + "available_schedules": ["off"], "climate_mode": "off", "dev_class": "climate", "model": "ThermoZone", "name": "Slaapkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": null, + "select_schedule": "off", "select_zone_profile": "active", "sensors": { "temperature": 24.2 @@ -27,14 +27,14 @@ }, "13228dab8ce04617af318a2888b3c548": { "active_preset": "home", - "available_schedules": [], + "available_schedules": ["off"], "climate_mode": "heat", "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Woonkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": null, + "select_schedule": "off", "select_zone_profile": "active", "sensors": { "temperature": 27.4 @@ -244,14 +244,14 @@ }, "d27aede973b54be484f6842d1b2802ad": { "active_preset": "home", - "available_schedules": [], + "available_schedules": ["off"], "climate_mode": "heat", "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Kinderkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": null, + "select_schedule": "off", "select_zone_profile": "active", "sensors": { "temperature": 30.0 @@ -295,14 +295,14 @@ }, "d58fec52899f4f1c92e4f8fad6d8c48c": { "active_preset": "home", - "available_schedules": [], + "available_schedules": ["off"], "climate_mode": "heat", "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Logeerkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": null, + "select_schedule": "off", "select_zone_profile": "active", "sensors": { "temperature": 30.0 diff --git a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json index 981c094ceee1f..491d07cdbd3a9 100644 --- a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json +++ b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json @@ -23,12 +23,12 @@ "08963fec7c53423ca5680aa4cb502c63": { "active_preset": "away", "available_schedules": [ + "off", "CV Roan", "Bios Schema met Film Avond", "GF7 Woonkamer", "Badkamer Schema", - "CV Jessie", - "off" + "CV Jessie" ], "climate_mode": "auto", "control_state": "idle", @@ -58,12 +58,12 @@ "12493538af164a409c6a1c79e38afe1c": { "active_preset": "away", "available_schedules": [ + "off", "CV Roan", "Bios Schema met Film Avond", "GF7 Woonkamer", "Badkamer Schema", - "CV Jessie", - "off" + "CV Jessie" ], "climate_mode": "heat", "control_state": "idle", @@ -112,14 +112,14 @@ }, "446ac08dd04d4eff8ac57489757b7314": { "active_preset": "no_frost", - "available_schedules": [], + "available_schedules": ["off"], "climate_mode": "heat", "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Garage", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": null, + "select_schedule": "off", "sensors": { "temperature": 15.6 }, @@ -254,12 +254,12 @@ "82fa13f017d240daa0d0ea1775420f24": { "active_preset": "asleep", "available_schedules": [ + "off", "CV Roan", "Bios Schema met Film Avond", "GF7 Woonkamer", "Badkamer Schema", - "CV Jessie", - "off" + "CV Jessie" ], "climate_mode": "auto", "control_state": "idle", @@ -399,12 +399,12 @@ "c50f167537524366a5af7aa3942feb1e": { "active_preset": "home", "available_schedules": [ + "off", "CV Roan", "Bios Schema met Film Avond", "GF7 Woonkamer", "Badkamer Schema", - "CV Jessie", - "off" + "CV Jessie" ], "climate_mode": "auto", "control_state": "heating", diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/data.json index af10692759ed6..e15bd162b0258 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/data.json @@ -64,7 +64,7 @@ }, "3cb70739631c4d17a86b8b12e8a5161b": { "active_preset": "home", - "available_schedules": ["standaard", "off"], + "available_schedules": ["off", "standaard"], "climate_mode": "auto", "control_state": "cooling", "dev_class": "thermostat", diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index 1a8e2f481501d..894db19ef8a70 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -25,12 +25,12 @@ '08963fec7c53423ca5680aa4cb502c63': dict({ 'active_preset': 'away', 'available_schedules': list([ + 'off', 'CV Roan', 'Bios Schema met Film Avond', 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', - 'off', ]), 'climate_mode': 'auto', 'control_state': 'idle', @@ -67,12 +67,12 @@ '12493538af164a409c6a1c79e38afe1c': dict({ 'active_preset': 'away', 'available_schedules': list([ + 'off', 'CV Roan', 'Bios Schema met Film Avond', 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', - 'off', ]), 'climate_mode': 'heat', 'control_state': 'idle', @@ -132,6 +132,7 @@ '446ac08dd04d4eff8ac57489757b7314': dict({ 'active_preset': 'no_frost', 'available_schedules': list([ + 'off', ]), 'climate_mode': 'heat', 'control_state': 'idle', @@ -145,7 +146,7 @@ 'vacation', 'no_frost', ]), - 'select_schedule': None, + 'select_schedule': 'off', 'sensors': dict({ 'temperature': 15.6, }), @@ -283,12 +284,12 @@ '82fa13f017d240daa0d0ea1775420f24': dict({ 'active_preset': 'asleep', 'available_schedules': list([ + 'off', 'CV Roan', 'Bios Schema met Film Avond', 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', - 'off', ]), 'climate_mode': 'auto', 'control_state': 'idle', @@ -438,12 +439,12 @@ 'c50f167537524366a5af7aa3942feb1e': dict({ 'active_preset': 'home', 'available_schedules': list([ + 'off', 'CV Roan', 'Bios Schema met Film Avond', 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', - 'off', ]), 'climate_mode': 'auto', 'control_state': 'heating', diff --git a/tests/components/plugwise/snapshots/test_select.ambr b/tests/components/plugwise/snapshots/test_select.ambr index 98fbdafc7f5f8..fa627eb528c06 100644 --- a/tests/components/plugwise/snapshots/test_select.ambr +++ b/tests/components/plugwise/snapshots/test_select.ambr @@ -133,11 +133,11 @@ 'area_id': None, 'capabilities': dict({ : list([ + 'off', 'Badkamer', 'Vakantie', 'Weekschema', 'Test', - 'off', ]), }), 'config_entry_id': , @@ -175,11 +175,11 @@ 'attributes': ReadOnlyDict({ : 'Bathroom Thermostat schedule', : list([ + 'off', 'Badkamer', 'Vakantie', 'Weekschema', 'Test', - 'off', ]), }), 'context': , @@ -259,11 +259,11 @@ 'area_id': None, 'capabilities': dict({ : list([ + 'off', 'Badkamer', 'Vakantie', 'Weekschema', 'Test', - 'off', ]), }), 'config_entry_id': , @@ -301,11 +301,11 @@ 'attributes': ReadOnlyDict({ : 'Living room Thermostat schedule', : list([ + 'off', 'Badkamer', 'Vakantie', 'Weekschema', 'Test', - 'off', ]), }), 'context': , @@ -385,12 +385,12 @@ 'area_id': None, 'capabilities': dict({ : list([ + 'off', 'CV Roan', 'Bios Schema met Film Avond', 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', - 'off', ]), }), 'config_entry_id': , @@ -428,12 +428,12 @@ 'attributes': ReadOnlyDict({ : 'Badkamer Thermostat schedule', : list([ + 'off', 'CV Roan', 'Bios Schema met Film Avond', 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', - 'off', ]), }), 'context': , @@ -452,12 +452,12 @@ 'area_id': None, 'capabilities': dict({ : list([ + 'off', 'CV Roan', 'Bios Schema met Film Avond', 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', - 'off', ]), }), 'config_entry_id': , @@ -495,12 +495,12 @@ 'attributes': ReadOnlyDict({ : 'Bios Thermostat schedule', : list([ + 'off', 'CV Roan', 'Bios Schema met Film Avond', 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', - 'off', ]), }), 'context': , @@ -511,6 +511,63 @@ 'state': 'off', }) # --- +# name: test_adam_select_entities[platforms0][select.garage_thermostat_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.garage_thermostat_schedule', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Thermostat schedule', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat schedule', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'select_schedule', + 'unique_id': '446ac08dd04d4eff8ac57489757b7314-select_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_select_entities[platforms0][select.garage_thermostat_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Garage Thermostat schedule', + : list([ + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.garage_thermostat_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_adam_select_entities[platforms0][select.jessie_thermostat_schedule-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -519,12 +576,12 @@ 'area_id': None, 'capabilities': dict({ : list([ + 'off', 'CV Roan', 'Bios Schema met Film Avond', 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', - 'off', ]), }), 'config_entry_id': , @@ -562,12 +619,12 @@ 'attributes': ReadOnlyDict({ : 'Jessie Thermostat schedule', : list([ + 'off', 'CV Roan', 'Bios Schema met Film Avond', 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', - 'off', ]), }), 'context': , @@ -586,12 +643,12 @@ 'area_id': None, 'capabilities': dict({ : list([ + 'off', 'CV Roan', 'Bios Schema met Film Avond', 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', - 'off', ]), }), 'config_entry_id': , @@ -629,12 +686,12 @@ 'attributes': ReadOnlyDict({ : 'Woonkamer Thermostat schedule', : list([ + 'off', 'CV Roan', 'Bios Schema met Film Avond', 'GF7 Woonkamer', 'Badkamer Schema', 'CV Jessie', - 'off', ]), }), 'context': , @@ -653,9 +710,9 @@ 'area_id': None, 'capabilities': dict({ : list([ + 'off', 'Winter', 'Test ', - 'off', ]), }), 'config_entry_id': , @@ -693,9 +750,9 @@ 'attributes': ReadOnlyDict({ : 'Anna Thermostat schedule', : list([ + 'off', 'Winter', 'Test ', - 'off', ]), }), 'context': , diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index 6b400f0f4f25d..479479c824997 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -169,7 +169,7 @@ async def test_adam_restore_state_climate( ( State("climate.living_room", "heat"), PlugwiseClimateExtraStoredData( - last_active_schedule=None, + last_active_schedule="off", previous_action_mode="heating", ).as_dict(), ), @@ -190,8 +190,6 @@ async def test_adam_restore_state_climate( assert (state := hass.states.get("climate.living_room")) assert state.state == "heat" - # Verify a HomeAssistantError is raised setting a schedule - # with last_active_schedule = None with pytest.raises(HomeAssistantError): await hass.services.async_call( CLIMATE_DOMAIN, @@ -233,7 +231,7 @@ async def test_adam_restore_state_climate( assert (state := hass.states.get("climate.bathroom")) assert state.state == "heat" - # Verify restoration is used when setting the schedule, schedule == "off" + # Verify restoration is used when setting the schedule, from "off" await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, @@ -287,13 +285,12 @@ async def test_adam_2_climate_snapshot( @pytest.mark.parametrize("chosen_env", ["m_adam_heating_off_schedule"], indirect=True) @pytest.mark.parametrize("cooling_present", [False], indirect=True) @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_adam_off_regulation_mode_change( +async def test_adam_none_restore( hass: HomeAssistant, mock_smile_adam_heat_cool: MagicMock, mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: - """Test changing from regulation off mode.""" + """Test last_active_schedule restored as None from before the plugwise v1.14.3 bump.""" mock_restore_cache_with_extra_data( hass, [ @@ -318,10 +315,7 @@ async def test_adam_off_regulation_mode_change( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert (state := hass.states.get("climate.living_room")) - assert state.state == "off" - - # Verify a HomeAssistantError is raised setting a schedule from regulation-off-mode with last_active_schedule = None + # Verify that setting a schedule with last_active_schedule=None fails with pytest.raises(HomeAssistantError): await hass.services.async_call( CLIMATE_DOMAIN, @@ -330,6 +324,43 @@ async def test_adam_off_regulation_mode_change( blocking=True, ) + +@pytest.mark.parametrize("chosen_env", ["m_adam_heating_off_schedule"], indirect=True) +@pytest.mark.parametrize("cooling_present", [False], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_off_regulation_mode_change( + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test changing from regulation off mode.""" + mock_restore_cache_with_extra_data( + hass, + [ + ( + State("climate.living_room", "heat"), + PlugwiseClimateExtraStoredData( + last_active_schedule="off", + previous_action_mode="heating", + ).as_dict(), + ), + ( + State("climate.bathroom", "heat"), + PlugwiseClimateExtraStoredData( + last_active_schedule="Badkamer", + previous_action_mode="heating", + ).as_dict(), + ), + ], + ) + + 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 (state := hass.states.get("climate.living_room")) + assert state.state == "off" + # Verify that the active schedule is turned off when transitioning from regulation-off-mode to a manual mode await hass.services.async_call( CLIMATE_DOMAIN, @@ -346,13 +377,16 @@ async def test_adam_off_regulation_mode_change( @pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_adam_3_climate_entity_attributes( +async def test_adam_climate_entity_attributes( hass: HomeAssistant, mock_smile_adam_heat_cool: MagicMock, mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: - """Test creation of adam climate device environment.""" + """Test creation of adam climate device environment. + + Restored data is according to the plugwise v1.14.3 updated format. + """ mock_restore_cache_with_extra_data( hass, [ @@ -652,8 +686,8 @@ async def test_anna_climate_entity_climate_changes( # Mock user deleting last schedule from app or browser data = mock_smile_anna.async_update.return_value - data["3cb70739631c4d17a86b8b12e8a5161b"]["available_schedules"] = [] - data["3cb70739631c4d17a86b8b12e8a5161b"]["select_schedule"] = None + data["3cb70739631c4d17a86b8b12e8a5161b"]["available_schedules"] = ["off"] + data["3cb70739631c4d17a86b8b12e8a5161b"]["select_schedule"] = "off" data["3cb70739631c4d17a86b8b12e8a5161b"]["climate_mode"] = "heat_cool" with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): freezer.tick(timedelta(minutes=1)) @@ -710,3 +744,18 @@ async def test_tom_without_temperature_measurement( assert (state := hass.states.get("climate.bathroom")) is not None assert state.state != STATE_UNAVAILABLE assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None + + +async def test_legacy_anna_no_schedule( + hass: HomeAssistant, + mock_smile_legacy_anna: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test failing to set a schedule with no schedule defined.""" + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.anna", ATTR_HVAC_MODE: HVACMode.AUTO}, + blocking=True, + ) diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index 96e410897b432..a719e3c10dd75 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -153,8 +153,9 @@ async def test_legacy_anna_select_entities( mock_smile_legacy_anna: MagicMock, init_integration: MockConfigEntry, ) -> None: - """Test no select-entity for legacy Anna without schedule.""" - assert not hass.states.get("select.anna_thermostat_schedule") + """Test that "off" is the selected option for legacy Anna without schedule.""" + assert (state := hass.states.get("select.anna_thermostat_schedule")) + assert state.state == "off" @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) diff --git a/tests/components/proxmoxve/snapshots/test_button.ambr b/tests/components/proxmoxve/snapshots/test_button.ambr index ff74d97cd4f7a..d3475674bb5ed 100644 --- a/tests/components/proxmoxve/snapshots/test_button.ambr +++ b/tests/components/proxmoxve/snapshots/test_button.ambr @@ -752,56 +752,6 @@ 'state': 'unknown', }) # --- -# name: test_all_button_entities[button.vm_web_hibernate-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.vm_web_hibernate', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Hibernate', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Hibernate', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'hibernate', - 'unique_id': '1234_100_hibernate', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_web_hibernate-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-web Hibernate', - }), - 'context': , - 'entity_id': 'button.vm_web_hibernate', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_button_entities[button.vm_web_reset-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1053,3 +1003,53 @@ 'state': 'unknown', }) # --- +# name: test_all_button_entities[button.vm_web_suspend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.vm_web_suspend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Suspend', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Suspend', + 'platform': 'proxmoxve', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hibernate', + 'unique_id': '1234_100_hibernate', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities[button.vm_web_suspend-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'vm-web Suspend', + }), + 'context': , + 'entity_id': 'button.vm_web_suspend', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/proxmoxve/test_button.py b/tests/components/proxmoxve/test_button.py index abf2bd171bdcd..7e216cbdfadb0 100644 --- a/tests/components/proxmoxve/test_button.py +++ b/tests/components/proxmoxve/test_button.py @@ -112,7 +112,7 @@ async def test_node_all_actions_buttons( ("button.vm_web_start", 100, "start"), ("button.vm_web_stop", 100, "stop"), ("button.vm_web_restart", 100, "reboot"), - ("button.vm_web_hibernate", 100, "hibernate"), + ("button.vm_web_suspend", 100, "suspend"), ("button.vm_web_reset", 100, "reset"), ("button.vm_web_shut_down", 100, "shutdown"), ], @@ -268,9 +268,9 @@ async def test_node_buttons_exceptions( SSLError("ssl error"), ), ( - "button.vm_web_hibernate", + "button.vm_web_suspend", 100, - "hibernate", + "suspend", ConnectTimeout("timeout"), ), ( diff --git a/tests/components/reolink/snapshots/test_button.ambr b/tests/components/reolink/snapshots/test_button.ambr index 4ce6df9b29b37..be44cf72fd9f3 100644 --- a/tests/components/reolink/snapshots/test_button.ambr +++ b/tests/components/reolink/snapshots/test_button.ambr @@ -912,3 +912,53 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[button.test_reolink_name_synchronize_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': 'button', + 'entity_category': , + 'entity_id': 'button.test_reolink_name_synchronize_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Synchronize time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Synchronize time', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sync_time', + 'unique_id': 'ABC1234567D89EFG_sync_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_name_synchronize_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Synchronize time', + }), + 'context': , + 'entity_id': 'button.test_reolink_name_synchronize_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/reolink/snapshots/test_select.ambr b/tests/components/reolink/snapshots/test_select.ambr index a637664e0777a..73f2afc194fa8 100644 --- a/tests/components/reolink/snapshots/test_select.ambr +++ b/tests/components/reolink/snapshots/test_select.ambr @@ -1,4 +1,67 @@ # serializer version: 1 +# name: test_all_entities[select.test_reolink_cam_anti_flicker-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'other', + 'f50', + 'f60', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_anti_flicker', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Anti-flicker', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Anti-flicker', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'anti_flicker', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_anti_flicker', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_anti_flicker-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Anti-flicker', + : list([ + 'off', + 'other', + 'f50', + 'f60', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_anti_flicker', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[select.test_reolink_cam_auto_quick_reply_message-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/tplink/snapshots/test_climate.ambr b/tests/components/tplink/snapshots/test_climate.ambr index 2b9c1823c7c57..eb881c3e6c63b 100644 --- a/tests/components/tplink/snapshots/test_climate.ambr +++ b/tests/components/tplink/snapshots/test_climate.ambr @@ -73,6 +73,10 @@ 'config_subentry_id': , 'configuration_url': None, 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), }), 'disabled_by': None, 'entry_type': None, @@ -93,6 +97,6 @@ 'name_by_user': None, 'serial_number': None, 'sw_version': '1.0.0', - 'via_device_id': , + 'via_device_id': None, }) # --- diff --git a/tests/components/tplink/test_climate.py b/tests/components/tplink/test_climate.py index 4d4e005fc16ef..3da0b2c023f54 100644 --- a/tests/components/tplink/test_climate.py +++ b/tests/components/tplink/test_climate.py @@ -109,6 +109,26 @@ async def test_states( ) +async def test_device_does_not_link_via_self( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + mocked_hub: Device, +) -> None: + """Test a child sharing its parent's device_id does not link to itself.""" + await setup_platform_for_device( + hass, mock_config_entry, Platform.CLIMATE, mocked_hub + ) + + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert devices + for device in devices: + assert device.via_device_id != device.id + + async def test_set_temperature( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mocked_hub: Device ) -> None: diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index 6a0c2cfc2ae86..2ab9c6ef04037 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -51,6 +51,7 @@ Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -646,12 +647,13 @@ async def test_setup_with_apps_additional_apps_config( CUSTOM_CONFIG_OBJ, ) - # Test that invalid app does nothing + # Test that invalid app raises with ( patch("homeassistant.components.vizio.Vizio.launch_app") as service_call1, patch( "homeassistant.components.vizio.Vizio.launch_app_config" ) as service_call2, + pytest.raises(ServiceValidationError, match='Source "_" is not valid'), ): await hass.services.async_call( MP_DOMAIN, @@ -659,8 +661,8 @@ async def test_setup_with_apps_additional_apps_config( service_data={ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "_"}, blocking=True, ) - assert not service_call1.called - assert not service_call2.called + assert not service_call1.called + assert not service_call2.called @pytest.mark.usefixtures("vizio_connect", "vizio_update_with_apps") @@ -881,3 +883,47 @@ async def test_sound_mode_list_cached( attr = hass.states.get(ENTITY_ID).attributes # Sound mode list should still be the original cached list assert attr["sound_mode_list"] == EQ_LIST + + +@pytest.mark.usefixtures("vizio_connect", "vizio_update") +async def test_select_invalid_sound_mode( + hass: HomeAssistant, mock_speaker_config_entry: MockConfigEntry +) -> None: + """Test selecting an invalid sound mode raises.""" + await _test_setup_speaker(hass, mock_speaker_config_entry, True) + + with ( + patch("homeassistant.components.vizio.Vizio.set_setting") as set_setting, + pytest.raises( + ServiceValidationError, match='Sound mode "invalid" is not valid' + ), + ): + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + service_data={ATTR_ENTITY_ID: ENTITY_ID, ATTR_SOUND_MODE: "invalid"}, + blocking=True, + ) + set_setting.assert_not_called() + + +@pytest.mark.usefixtures("vizio_connect", "vizio_update") +async def test_command_error_raises( + hass: HomeAssistant, mock_speaker_config_entry: MockConfigEntry +) -> None: + """Test a device command failure raises HomeAssistantError.""" + await _test_setup_speaker(hass, mock_speaker_config_entry, True) + + with ( + patch( + "homeassistant.components.vizio.Vizio.power_on", + side_effect=VizioConnectionError("cannot connect"), + ), + pytest.raises(HomeAssistantError, match="Failed to send command"), + ): + await hass.services.async_call( + MP_DOMAIN, + SERVICE_TURN_ON, + service_data={ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + )