diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 247c87562eef4..71529b107b5c9 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.1"] + "requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.4"] } diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index dd0c863b61586..d03d6a7b1f4d9 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -29,7 +29,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["axis"], - "requirements": ["axis==65"], + "requirements": ["axis==66"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/homeassistant/components/dynalite/panel.py b/homeassistant/components/dynalite/panel.py index 623736cf02a05..49983564d688d 100644 --- a/homeassistant/components/dynalite/panel.py +++ b/homeassistant/components/dynalite/panel.py @@ -110,7 +110,7 @@ async def async_register_dynalite_frontend(hass: HomeAssistant): frontend_url_path=DOMAIN, config_panel_domain=DOMAIN, webcomponent_name="dynalite-panel", - module_url=f"{URL_BASE}/entrypoint-{build_id}.js", + module_url=f"{URL_BASE}/entrypoint.{build_id}.js", embed_iframe=True, require_admin=True, ) diff --git a/homeassistant/components/ecoforest/strings.json b/homeassistant/components/ecoforest/strings.json index 73260da272a07..2d195792943ce 100644 --- a/homeassistant/components/ecoforest/strings.json +++ b/homeassistant/components/ecoforest/strings.json @@ -35,7 +35,7 @@ "cpu_overheating": "CPU overheating", "none": "None", "pellets": "Pellets", - "unkownn": "Unknown alarm" + "unknown": "Unknown alarm" } }, "convector_air_flow": { diff --git a/homeassistant/components/insteon/api/__init__.py b/homeassistant/components/insteon/api/__init__.py index d277a4b3cafe1..c190fdac9a44d 100644 --- a/homeassistant/components/insteon/api/__init__.py +++ b/homeassistant/components/insteon/api/__init__.py @@ -107,7 +107,7 @@ async def async_register_insteon_frontend(hass: HomeAssistant): frontend_url_path=DOMAIN, webcomponent_name="insteon-frontend", config_panel_domain=DOMAIN, - module_url=f"{URL_BASE}/entrypoint-{build_id}.js", + module_url=f"{URL_BASE}/entrypoint.{build_id}.js", embed_iframe=True, require_admin=True, ) diff --git a/homeassistant/components/insteon/api/properties.py b/homeassistant/components/insteon/api/properties.py index ac633e2a45715..6edc5d2ef3d3b 100644 --- a/homeassistant/components/insteon/api/properties.py +++ b/homeassistant/components/insteon/api/properties.py @@ -282,6 +282,8 @@ async def websocket_reset_properties( notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) return + for prop in device.configuration.values(): + prop.new_value = None for prop in device.operating_flags: device.operating_flags[prop].new_value = None for prop in device.properties: diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index c91276402502f..f8b624071eb1c 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -18,7 +18,7 @@ "loggers": ["pyinsteon", "pypubsub"], "requirements": [ "pyinsteon==1.6.3", - "insteon-frontend-home-assistant==0.5.0" + "insteon-frontend-home-assistant==0.6.0" ], "single_config_entry": true, "usb": [ diff --git a/homeassistant/components/melissa/manifest.json b/homeassistant/components/melissa/manifest.json index f00e0397d92dd..c7e051abfebe9 100644 --- a/homeassistant/components/melissa/manifest.json +++ b/homeassistant/components/melissa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["melissa"], "quality_scale": "legacy", - "requirements": ["py-melissa-climate==3.0.2"] + "requirements": ["py-melissa-climate==3.0.3"] } diff --git a/homeassistant/components/nuki/sensor.py b/homeassistant/components/nuki/sensor.py index 809e97d6ce95b..46bb165543da7 100644 --- a/homeassistant/components/nuki/sensor.py +++ b/homeassistant/components/nuki/sensor.py @@ -4,7 +4,11 @@ from pynuki.device import NukiDevice -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant @@ -34,6 +38,7 @@ class NukiBatterySensor(NukiEntity[NukiDevice], SensorEntity): _attr_has_entity_name = True _attr_native_unit_of_measurement = PERCENTAGE _attr_device_class = SensorDeviceClass.BATTERY + _attr_state_class = SensorStateClass.MEASUREMENT _attr_entity_category = EntityCategory.DIAGNOSTIC @property diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index a63fa0e65c105..dbbacf5dc57ea 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -76,6 +76,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> show_background=entry.options.get(CONF_SHOW_BACKGROUND, False), map_scale=MAP_SCALE, ), + mqtt_session_unauthorized_hook=lambda: entry.async_start_reauth(hass), ) except RoborockInvalidCredentials as err: raise ConfigEntryAuthFailed( diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index baf1973cc0068..80d254b3e7b8c 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -111,6 +111,7 @@ def __init__( # Tracks the last successful update to control when we report failure # to the base class. This is reset on successful data update. self._last_update_success_time: datetime | None = None + self._has_connected_locally: bool = False @cached_property def dock_device_info(self) -> DeviceInfo: @@ -180,7 +181,8 @@ async def update_map(self) -> None: async def _verify_api(self) -> None: """Verify that the api is reachable.""" if self._device.is_connected: - if self._device.is_local_connected: + self._has_connected_locally |= self._device.is_local_connected + if self._has_connected_locally: async_delete_issue( self.hass, DOMAIN, f"cloud_api_used_{self.duid_slug}" ) @@ -223,6 +225,7 @@ async def _update_device_prop(self) -> None: async def _async_update_data(self) -> DeviceState: """Update data via library.""" + await self._verify_api() try: # Update device props and standard api information await self._update_device_prop() diff --git a/homeassistant/components/roborock/diagnostics.py b/homeassistant/components/roborock/diagnostics.py index 02231909908cb..642dd254c2179 100644 --- a/homeassistant/components/roborock/diagnostics.py +++ b/homeassistant/components/roborock/diagnostics.py @@ -13,7 +13,22 @@ _LOGGER = logging.getLogger(__name__) -TO_REDACT_CONFIG = ["token", "sn", "rruid", CONF_UNIQUE_ID, "username", "uid"] +TO_REDACT_CONFIG = [ + "token", + "sn", + "rruid", + CONF_UNIQUE_ID, + "username", + "uid", + "h", + "k", + "s", + "u", + "avatarurl", + "nickname", + "tuyaUuid", + "extra", +] async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 993081f8049c4..51f6e65407c1e 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -20,7 +20,7 @@ "loggers": ["roborock"], "quality_scale": "silver", "requirements": [ - "python-roborock==3.19.0", + "python-roborock==3.21.0", "vacuum-map-parser-roborock==0.1.4" ] } diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index 2927dcf268324..59c4f71d9c80d 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -92,10 +92,8 @@ async def async_turn_on(self, **kwargs: Any) -> None: LOGGER.debug("Attempting to turn on %s via automation", self.entity_id) await self._turn_on_action.async_run(self.hass, self._context) elif self._mac: - LOGGER.warning( - "Attempting to turn on %s via Wake-On-Lan; if this does not work, " - "please ensure that Wake-On-Lan is available for your device or use " - "a turn_on automation", + LOGGER.debug( + "Attempting to turn on %s via Wake-On-Lan", self.entity_id, ) await self.hass.async_add_executor_job(self._wake_on_lan) diff --git a/homeassistant/components/senz/__init__.py b/homeassistant/components/senz/__init__.py index 59a5f3cf2762c..bd96d6480e7d3 100644 --- a/homeassistant/components/senz/__init__.py +++ b/homeassistant/components/senz/__init__.py @@ -6,9 +6,9 @@ from http import HTTPStatus import logging -from aiosenz import SENZAPI, Thermostat from httpx import HTTPStatusError, RequestError import jwt +from pysenz import SENZAPI, Thermostat from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform diff --git a/homeassistant/components/senz/api.py b/homeassistant/components/senz/api.py index fa139ac9c6410..1500f1bf6456e 100644 --- a/homeassistant/components/senz/api.py +++ b/homeassistant/components/senz/api.py @@ -2,8 +2,8 @@ from typing import cast -from aiosenz import AbstractSENZAuth from httpx import AsyncClient +from pysenz import AbstractSENZAuth from homeassistant.helpers import config_entry_oauth2_flow diff --git a/homeassistant/components/senz/application_credentials.py b/homeassistant/components/senz/application_credentials.py index 205f00ff33f50..fdaaa7582a032 100644 --- a/homeassistant/components/senz/application_credentials.py +++ b/homeassistant/components/senz/application_credentials.py @@ -1,6 +1,6 @@ """Application credentials platform for senz.""" -from aiosenz import AUTHORIZATION_ENDPOINT, TOKEN_ENDPOINT +from pysenz import AUTHORIZATION_ENDPOINT, TOKEN_ENDPOINT from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/senz/climate.py b/homeassistant/components/senz/climate.py index 9f96ca37a81fc..bde683d60d989 100644 --- a/homeassistant/components/senz/climate.py +++ b/homeassistant/components/senz/climate.py @@ -4,8 +4,8 @@ from typing import Any -from aiosenz import MODE_AUTO, Thermostat from httpx import RequestError +from pysenz import MODE_AUTO, Thermostat from homeassistant.components.climate import ( ClimateEntity, diff --git a/homeassistant/components/senz/config_flow.py b/homeassistant/components/senz/config_flow.py index 8dc164f229c19..4046a85e2a473 100644 --- a/homeassistant/components/senz/config_flow.py +++ b/homeassistant/components/senz/config_flow.py @@ -33,7 +33,7 @@ def logger(self) -> logging.Logger: @property def extra_authorize_data(self) -> dict: """Extra data that needs to be appended to the authorize url.""" - return {"scope": "restapi offline_access"} + return {"scope": "restapi offline_access openid"} async def async_step_reauth( self, entry_data: Mapping[str, Any] diff --git a/homeassistant/components/senz/manifest.json b/homeassistant/components/senz/manifest.json index 9385de830d8ab..96f4f7e02b1e4 100644 --- a/homeassistant/components/senz/manifest.json +++ b/homeassistant/components/senz/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/senz", "iot_class": "cloud_polling", - "loggers": ["aiosenz"], - "requirements": ["aiosenz==1.0.0"] + "loggers": ["pysenz"], + "requirements": ["pysenz==1.0.2"] } diff --git a/homeassistant/components/senz/sensor.py b/homeassistant/components/senz/sensor.py index 444b787d39af4..bc6e39ce5f48c 100644 --- a/homeassistant/components/senz/sensor.py +++ b/homeassistant/components/senz/sensor.py @@ -5,7 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass -from aiosenz import Thermostat +from pysenz import Thermostat from homeassistant.components.sensor import ( SensorDeviceClass, diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py index 48ebeece1ba73..3d7d048a54258 100644 --- a/homeassistant/components/solarlog/coordinator.py +++ b/homeassistant/components/solarlog/coordinator.py @@ -7,6 +7,7 @@ import logging from urllib.parse import ParseResult, urlparse +from aiohttp import CookieJar from solarlog_cli.solarlog_connector import SolarLogConnector from solarlog_cli.solarlog_exceptions import ( SolarLogAuthenticationError, @@ -20,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import slugify @@ -63,7 +64,9 @@ def __init__(self, hass: HomeAssistant, config_entry: SolarlogConfigEntry) -> No self.host, tz=hass.config.time_zone, password=password, - session=async_get_clientsession(hass), + session=async_create_clientsession( + hass, cookie_jar=CookieJar(quote_cookie=False) + ), ) async def _async_setup(self) -> None: diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 0231fca42dd5e..33d82e072882c 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -5,6 +5,7 @@ import asyncio import datetime from functools import partial +from http import HTTPStatus from ipaddress import AddressValueError, IPv4Address import logging import socket @@ -12,7 +13,7 @@ from urllib.parse import urlparse from aiohttp import ClientError -from requests.exceptions import Timeout +from requests.exceptions import HTTPError, Timeout from soco import events_asyncio, zonegroupstate import soco.config as soco_config from soco.core import SoCo @@ -54,6 +55,8 @@ SUB_FAIL_ISSUE_ID, SUB_FAIL_URL, SUBSCRIPTION_TIMEOUT, + UPNP_DOCUMENTATION_URL, + UPNP_ISSUE_ID, UPNP_ST, ) from .exception import SonosUpdateError @@ -184,6 +187,32 @@ def is_device_invisible(self, ip_address: str) -> bool: """Check if device at provided IP is known to be invisible.""" return any(x for x in self._known_invisible if x.ip_address == ip_address) + async def _process_http_connection_error( + self, err: HTTPError, ip_address: str + ) -> None: + """Process HTTP Errors when connecting to a Sonos speaker.""" + response = err.response + # When UPnP is disabled, Sonos returns HTTP 403 Forbidden error. + # Create issue advising user to enable UPnP on Sonos system. + if response is not None and response.status_code == HTTPStatus.FORBIDDEN: + ir.async_create_issue( + self.hass, + DOMAIN, + f"{UPNP_ISSUE_ID}_{ip_address}", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="upnp_disabled", + translation_placeholders={ + "device_ip": ip_address, + "documentation_url": UPNP_DOCUMENTATION_URL, + }, + ) + _LOGGER.error( + "HTTP error connecting to Sonos speaker at %s: %s", + ip_address, + err, + ) + async def async_subscribe_to_zone_updates(self, ip_address: str) -> None: """Test subscriptions and create SonosSpeakers based on results.""" try: @@ -195,13 +224,29 @@ async def async_subscribe_to_zone_updates(self, ip_address: str) -> None: ) return soco = SoCo(ip_address) - # Cache now to avoid household ID lookup during first ZoneGroupState processing - await self.hass.async_add_executor_job( - getattr, - soco, - "household_id", - ) - sub = await soco.zoneGroupTopology.subscribe() + try: + # Cache now to avoid household ID lookup during first ZoneGroupState processing + await self.hass.async_add_executor_job( + getattr, + soco, + "household_id", + ) + sub = await soco.zoneGroupTopology.subscribe() + except HTTPError as err: + await self._process_http_connection_error(err, ip_address) + return + except ( + OSError, + SoCoException, + Timeout, + TimeoutError, + ) as err: + _LOGGER.error( + "Error connecting to discovered Sonos speaker at %s: %s", + ip_address, + err, + ) + return @callback def _async_add_visible_zones(subscription_succeeded: bool = False) -> None: @@ -390,6 +435,9 @@ async def async_poll_manual_hosts( sync_get_visible_zones, soco, ) + except HTTPError as err: + await self._process_http_connection_error(err, ip_addr) + continue except ( OSError, SoCoException, diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 20e079c901d48..31db15f70cc66 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -20,6 +20,9 @@ Platform.SWITCH, ] +UPNP_ISSUE_ID = "upnp_disabled" +UPNP_DOCUMENTATION_URL = "https://www.home-assistant.io/integrations/sonos/#403-error-when-setting-up-the-integration" + SUB_FAIL_ISSUE_ID = "subscriptions_failed" SUB_FAIL_URL = "https://www.home-assistant.io/integrations/sonos/#network-requirements" diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index 28f6a7c4d61ae..71b6ffe6c63fb 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -132,6 +132,10 @@ "subscriptions_failed": { "description": "Falling back to polling, functionality may be limited.\n\nSonos device at {device_ip} cannot reach Home Assistant at {listener_address}.\n\nSee our [documentation]({sub_fail_url}) for more information on how to solve this issue.", "title": "Networking error: subscriptions failed" + }, + "upnp_disabled": { + "description": "Unable to connect to Sonos speaker at {device_ip}.\n\nPlease ensure UPnP is enabled on your Sonos system.\n\nOpen the Sonos app on your phone or tablet. Go to Account > Privacy and Security > UPnP. Enable the UPnP setting. Once UPnP is enabled, return to Home Assistant and reload the Sonos integration. The connection should now succeed. See our [documentation]({documentation_url}) for steps to resolve this issue.", + "title": "Networking error: UPnP disabled" } }, "services": { diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 5937fc86ae48c..b77bff3827bad 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.33.2", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.33.3", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/media_source.py b/homeassistant/components/unifiprotect/media_source.py index 1e36b59d6419b..704228a7bf7a1 100644 --- a/homeassistant/components/unifiprotect/media_source.py +++ b/homeassistant/components/unifiprotect/media_source.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from calendar import monthrange from datetime import date, datetime, timedelta from enum import Enum from typing import Any, NoReturn, cast @@ -94,11 +95,12 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource: @callback def _get_month_start_end(start: datetime) -> tuple[datetime, datetime]: + """Get the first day of the month for start and current time.""" start = dt_util.as_local(start) end = dt_util.now() - start = start.replace(day=1, hour=0, minute=0, second=1, microsecond=0) - end = end.replace(day=1, hour=0, minute=0, second=2, microsecond=0) + start = start.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + end = end.replace(day=1, hour=0, minute=0, second=0, microsecond=0) return start, end @@ -113,20 +115,19 @@ def _bad_identifier(identifier: str, err: Exception | None = None) -> NoReturn: @callback def _format_duration(duration: timedelta) -> str: - formatted = "" seconds = int(duration.total_seconds()) - if seconds > 3600: - hours = seconds // 3600 - formatted += f"{hours}h " - seconds -= hours * 3600 - if seconds > 60: - minutes = seconds // 60 - formatted += f"{minutes}m " - seconds -= minutes * 60 + hours, seconds = divmod(seconds, 3600) + minutes, seconds = divmod(seconds, 60) + + parts = [] + if hours > 0: + parts.append(f"{hours}h") + if minutes > 0: + parts.append(f"{minutes}m") if seconds > 0: - formatted += f"{seconds}s " + parts.append(f"{seconds}s") - return formatted.strip() + return " ".join(parts) if parts else "0s" @callback @@ -593,7 +594,8 @@ async def _build_month( start = max(recording_start, start) recording_end = dt_util.now().date() - end = start.replace(month=start.month + 1) - timedelta(days=1) + + end = start.replace(day=monthrange(start.year, start.month)[1]) end = min(recording_end, end) children = [self._build_days(data, camera_id, event_type, start, is_all=True)] @@ -660,10 +662,9 @@ async def _build_days( tzinfo=dt_util.get_default_time_zone(), ) if is_all: - if start_dt.month < 12: - end_dt = start_dt.replace(month=start_dt.month + 1) - else: - end_dt = start_dt.replace(year=start_dt.year + 1, month=1) + # Move to first day of next month + days_in_month = monthrange(start_dt.year, start_dt.month)[1] + end_dt = start_dt + timedelta(days=days_in_month) else: end_dt = start_dt + timedelta(hours=24) @@ -726,7 +727,7 @@ async def _build_events_type( ] start, end = _get_month_start_end(data.api.bootstrap.recording_start) - while end > start: + while end >= start: children.append(self._build_month(data, camera_id, event_type, end.date())) end = (end - timedelta(days=1)).replace(day=1) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index d4e1a794fa4bf..69cbb27e02099 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -14,7 +14,7 @@ "velbus-protocol" ], "quality_scale": "bronze", - "requirements": ["velbus-aio==2025.11.0"], + "requirements": ["velbus-aio==2025.12.0"], "usb": [ { "pid": "0B1B", diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index a854254a262e7..945b68a74cf66 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -2,6 +2,11 @@ from __future__ import annotations +import asyncio +import logging +from typing import TYPE_CHECKING + +from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -13,8 +18,11 @@ WLEDConfigEntry, WLEDDataUpdateCoordinator, WLEDReleasesDataUpdateCoordinator, + normalize_mac_address, ) +_LOGGER = logging.getLogger(__name__) + PLATFORMS = ( Platform.BUTTON, Platform.LIGHT, @@ -63,3 +71,69 @@ async def async_unload_entry(hass: HomeAssistant, entry: WLEDConfigEntry) -> boo coordinator.unsub() return unload_ok + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: WLEDConfigEntry +) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 1: + # The user has downgraded from a future version + return False + + if config_entry.version == 1: + if config_entry.minor_version < 2: + # 1.2: Normalize unique ID to be lowercase MAC address without separators. + # This matches the format used by WLED firmware. + if TYPE_CHECKING: + assert config_entry.unique_id + normalized_mac_address = normalize_mac_address(config_entry.unique_id) + duplicate_entries = [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.unique_id + and normalize_mac_address(entry.unique_id) == normalized_mac_address + ] + ignored_entries = [ + entry + for entry in duplicate_entries + if entry.entry_id != config_entry.entry_id + and entry.source == SOURCE_IGNORE + ] + if ignored_entries: + _LOGGER.info( + "Found %d ignored WLED config entries with the same MAC address, removing them", + len(ignored_entries), + ) + await asyncio.gather( + *[ + hass.config_entries.async_remove(entry.entry_id) + for entry in ignored_entries + ] + ) + if len(duplicate_entries) - len(ignored_entries) > 1: + _LOGGER.warning( + "Found multiple WLED config entries with the same MAC address, cannot migrate to version 1.2" + ) + return False + + hass.config_entries.async_update_entry( + config_entry, + unique_id=normalized_mac_address, + version=1, + minor_version=2, + ) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 337d608ae11da..5adb7f126d184 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -21,13 +21,14 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT, DOMAIN -from .coordinator import WLEDConfigEntry +from .coordinator import WLEDConfigEntry, normalize_mac_address class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a WLED config flow.""" VERSION = 1 + MINOR_VERSION = 2 discovered_host: str discovered_device: Device @@ -53,16 +54,15 @@ async def async_step_user( except WLEDConnectionError: errors["base"] = "cannot_connect" else: - await self.async_set_unique_id( - device.info.mac_address, raise_on_progress=False - ) + mac_address = normalize_mac_address(device.info.mac_address) + await self.async_set_unique_id(mac_address, raise_on_progress=False) if self.source == SOURCE_RECONFIGURE: entry = self._get_reconfigure_entry() self._abort_if_unique_id_mismatch( reason="unique_id_mismatch", description_placeholders={ "expected_mac": format_mac(entry.unique_id).upper(), - "actual_mac": format_mac(self.unique_id).upper(), + "actual_mac": mac_address.upper(), }, ) return self.async_update_reload_and_abort( @@ -104,7 +104,7 @@ async def async_step_zeroconf( """Handle zeroconf discovery.""" # Abort quick if the mac address is provided by discovery info if mac := discovery_info.properties.get(CONF_MAC): - await self.async_set_unique_id(mac) + await self.async_set_unique_id(normalize_mac_address(mac)) self._abort_if_unique_id_configured( updates={CONF_HOST: discovery_info.host} ) @@ -117,7 +117,10 @@ async def async_step_zeroconf( except WLEDConnectionError: return self.async_abort(reason="cannot_connect") - await self.async_set_unique_id(self.discovered_device.info.mac_address) + device_mac_address = normalize_mac_address( + self.discovered_device.info.mac_address + ) + await self.async_set_unique_id(device_mac_address) self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) self.context.update( diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index af3d1f583cb4d..eb876985c5741 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from wled import ( WLED, Device as WLEDDevice, @@ -32,6 +34,17 @@ type WLEDConfigEntry = ConfigEntry[WLEDDataUpdateCoordinator] +def normalize_mac_address(mac: str) -> str: + """Normalize a MAC address to lowercase without separators. + + This format is used by WLED firmware as well as unique IDs in Home Assistant. + + The homeassistant.helpers.device_registry.format_mac function is preferred but + returns MAC addresses with colons as separators. + """ + return mac.lower().replace(":", "").replace(".", "").replace("-", "").strip() + + class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): """Class to manage fetching WLED data from single endpoint.""" @@ -51,6 +64,10 @@ def __init__( self.wled = WLED(entry.data[CONF_HOST], session=async_get_clientsession(hass)) self.unsub: CALLBACK_TYPE | None = None + if TYPE_CHECKING: + assert entry.unique_id + self.config_mac_address = normalize_mac_address(entry.unique_id) + super().__init__( hass, LOGGER, @@ -131,13 +148,14 @@ async def _async_update_data(self) -> WLEDDevice: translation_placeholders={"error": str(error)}, ) from error - if device.info.mac_address != self.config_entry.unique_id: + device_mac_address = normalize_mac_address(device.info.mac_address) + if device_mac_address != self.config_mac_address: raise ConfigEntryError( translation_domain=DOMAIN, translation_key="mac_address_mismatch", translation_placeholders={ - "expected_mac": format_mac(self.config_entry.unique_id).upper(), - "actual_mac": format_mac(device.info.mac_address).upper(), + "expected_mac": format_mac(self.config_mac_address).upper(), + "actual_mac": format_mac(device_mac_address).upper(), }, ) diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index 537ee8c2eef5d..8acd61add7c36 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.1"] + "requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.4"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 2c5cdf65d7d5e..1c0fdaa0f061e 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==3.2.1"] + "requirements": ["yalexs-ble==3.2.4"] } diff --git a/homeassistant/const.py b/homeassistant/const.py index 061820a2a84e6..cb65f148b63a0 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "4" +PATCH_VERSION: Final = "5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index b25895c3e8f43..4593b0bd88c78 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -154,7 +154,11 @@ def converter_factory_allow_none( return lambda value: value from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) if cls._are_unit_inverses(from_unit, to_unit): - return lambda val: None if val is None else to_ratio / (val / from_ratio) + return ( + lambda val: None + if val is None or val == 0 + else to_ratio / (val / from_ratio) + ) return lambda val: None if val is None else (val / from_ratio) * to_ratio @classmethod diff --git a/pyproject.toml b/pyproject.toml index 9fd249f245008..393835b486894 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.12.4" +version = "2025.12.5" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." diff --git a/requirements_all.txt b/requirements_all.txt index 136f3483e2953..5ef2a150532dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -389,9 +389,6 @@ aiorussound==4.9.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 -# homeassistant.components.senz -aiosenz==1.0.0 - # homeassistant.components.shelly aioshelly==13.22.0 @@ -588,7 +585,7 @@ avea==1.6.1 # avion==0.10 # homeassistant.components.axis -axis==65 +axis==66 # homeassistant.components.fujitsu_fglair ayla-iot-unofficial==1.4.7 @@ -1276,7 +1273,7 @@ influxdb==5.3.1 inkbird-ble==1.1.1 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.5.0 +insteon-frontend-home-assistant==0.6.0 # homeassistant.components.intellifire intellifire4py==4.2.1 @@ -1801,7 +1798,7 @@ py-improv-ble-client==1.0.3 py-madvr2==1.6.40 # homeassistant.components.melissa -py-melissa-climate==3.0.2 +py-melissa-climate==3.0.3 # homeassistant.components.nextbus py-nextbusnext==2.3.0 @@ -2368,6 +2365,9 @@ pyschlage==2025.9.0 # homeassistant.components.sensibo pysensibo==1.2.1 +# homeassistant.components.senz +pysenz==1.0.2 + # homeassistant.components.serial pyserial-asyncio-fast==0.16 @@ -2560,7 +2560,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==3.19.0 +python-roborock==3.21.0 # homeassistant.components.smarttub python-smarttub==0.0.45 @@ -3053,7 +3053,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.33.2 +uiprotect==7.33.3 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -3097,7 +3097,7 @@ vegehub==0.1.26 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.11.0 +velbus-aio==2025.12.0 # homeassistant.components.venstar venstarcolortouch==0.21 @@ -3209,7 +3209,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==3.2.1 +yalexs-ble==3.2.4 # homeassistant.components.august # homeassistant.components.yale diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0d3854cbba0d5..3f8f952a468be 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -374,9 +374,6 @@ aiorussound==4.9.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 -# homeassistant.components.senz -aiosenz==1.0.0 - # homeassistant.components.shelly aioshelly==13.22.0 @@ -540,7 +537,7 @@ automower-ble==0.2.8 av==16.0.1 # homeassistant.components.axis -axis==65 +axis==66 # homeassistant.components.fujitsu_fglair ayla-iot-unofficial==1.4.7 @@ -1122,7 +1119,7 @@ influxdb==5.3.1 inkbird-ble==1.1.1 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.5.0 +insteon-frontend-home-assistant==0.6.0 # homeassistant.components.intellifire intellifire4py==4.2.1 @@ -1538,7 +1535,7 @@ py-improv-ble-client==1.0.3 py-madvr2==1.6.40 # homeassistant.components.melissa -py-melissa-climate==3.0.2 +py-melissa-climate==3.0.3 # homeassistant.components.nextbus py-nextbusnext==2.3.0 @@ -1991,6 +1988,9 @@ pyschlage==2025.9.0 # homeassistant.components.sensibo pysensibo==1.2.1 +# homeassistant.components.senz +pysenz==1.0.2 + # homeassistant.components.acer_projector # homeassistant.components.crownstone # homeassistant.components.route_b_smart_meter @@ -2138,7 +2138,7 @@ python-pooldose==0.7.8 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==3.19.0 +python-roborock==3.21.0 # homeassistant.components.smarttub python-smarttub==0.0.45 @@ -2538,7 +2538,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.33.2 +uiprotect==7.33.3 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2576,7 +2576,7 @@ vegehub==0.1.26 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.11.0 +velbus-aio==2025.12.0 # homeassistant.components.venstar venstarcolortouch==0.21 @@ -2670,7 +2670,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==3.2.1 +yalexs-ble==3.2.4 # homeassistant.components.august # homeassistant.components.yale diff --git a/script/licenses.py b/script/licenses.py index 15d10643fec35..64e0e2db82297 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -181,6 +181,7 @@ def from_dict(cls, data: PackageMetadata) -> PackageDefinition: "PySwitchmate", # https://github.com/Danielhiversen/pySwitchmate/pull/16 "PyXiaomiGateway", # https://github.com/Danielhiversen/PyXiaomiGateway/pull/201 "chacha20poly1305", # LGPL + "caio", # Apache 2 https://github.com/mosquito/caio/?tab=Apache-2.0-1-ov-file#readme "commentjson", # https://github.com/vaidik/commentjson/pull/55 "crownstone-cloud", # https://github.com/crownstone/crownstone-lib-python-cloud/pull/5 "crownstone-core", # https://github.com/crownstone/crownstone-lib-python-core/pull/6 diff --git a/tests/components/nuki/snapshots/test_sensor.ambr b/tests/components/nuki/snapshots/test_sensor.ambr index 55f2d1aac3cb0..f25d18e425a91 100644 --- a/tests/components/nuki/snapshots/test_sensor.ambr +++ b/tests/components/nuki/snapshots/test_sensor.ambr @@ -4,7 +4,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -39,6 +41,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Home Battery', + 'state_class': , 'unit_of_measurement': '%', }), 'context': , diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index 55e8af1f8595e..625abc0e2ca58 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -4,22 +4,22 @@ 'config_entry': dict({ 'base_url': 'https://usiot.roborock.com', 'user_data': dict({ - 'avatarurl': 'https://files.roborock.com/iottest/default_avatar.png', + 'avatarurl': '**REDACTED**', 'country': 'US', 'countrycode': '1', - 'nickname': 'user_nickname', + 'nickname': '**REDACTED**', 'region': 'us', 'rriot': dict({ - 'h': 'abc123', - 'k': 'abc123', + 'h': '**REDACTED**', + 'k': '**REDACTED**', 'r': dict({ 'a': 'https://api-us.roborock.com', 'l': 'https://wood-us.roborock.com', 'm': 'ssl://mqtt-us-2.roborock.com:8883', 'r': 'US', }), - 's': 'abc123', - 'u': 'abc123', + 's': '**REDACTED**', + 'u': '**REDACTED**', }), 'rruid': '**REDACTED**', 'token': '**REDACTED**', @@ -45,7 +45,7 @@ '128': 0, '133': 1, }), - 'duid': '**REDACTED**', + 'duid': '******bc123', 'extra': '{"RRPhotoPrivacyVersion": "1"}', 'featureSet': '2234201184108543', 'fv': '02.56.02', @@ -266,7 +266,7 @@ '128': 0, '133': 1, }), - 'duid': '**REDACTED**', + 'duid': '******ice_2', 'extra': '{"RRPhotoPrivacyVersion": "1"}', 'featureSet': '2234201184108543', 'fv': '02.56.02', @@ -510,7 +510,7 @@ '235': 0, '237': 0, }), - 'duid': '**REDACTED**', + 'duid': '******_duid', 'f': False, 'fv': '01.12.34', 'iconUrl': '', @@ -864,7 +864,7 @@ '227': 1, '232': 0, }), - 'duid': '**REDACTED**', + 'duid': '******_duid', 'f': False, 'featureSet': '0', 'fv': '01.00.94', diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 8ed1ebaad169f..e7f7dd529ad0c 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -92,6 +92,44 @@ async def test_reauth_started( assert flows[0]["step_id"] == "reauth_confirm" +async def test_mqtt_session_unauthorized_hook_called( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + device_manager: AsyncMock, +) -> None: + """Test that the mqtt session unauthorized hook is called on unauthorized event.""" + device_manager_kwargs = {} + + def create_device_manager(*args: Any, **kwargs: Any) -> AsyncMock: + nonlocal device_manager_kwargs + device_manager_kwargs = kwargs + return device_manager + + with patch( + "homeassistant.components.roborock.create_device_manager", + side_effect=create_device_manager, + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + assert mock_roborock_entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert not flows + + # Simulate an unauthorized event by calling the captured hook + assert device_manager_kwargs + mqtt_session_unauthorized_hook = device_manager_kwargs.get( + "mqtt_session_unauthorized_hook" + ) + assert mqtt_session_unauthorized_hook + mqtt_session_unauthorized_hook() + + # Verify that reauth flow is started + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + @pytest.mark.parametrize("platforms", [[Platform.IMAGE]]) @pytest.mark.parametrize( ("exists", "is_dir", "rmtree_called"), @@ -411,6 +449,63 @@ async def test_cloud_api_repair( assert len(issue_registry.issues) == 0 +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) +async def test_cloud_api_repair_cleared_on_update( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + fake_vacuum: FakeDevice, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a repair is created then cleared if the device is reachable locally again.""" + + # Fake that the device is only reachable via cloud + fake_vacuum.is_connected = True + fake_vacuum.is_local_connected = False + + # Load the integration and verify that a repair issue is created + await async_setup_component(hass, HA_DOMAIN, {}) + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + assert mock_roborock_entry.state is ConfigEntryState.LOADED + + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + + # Fake that the device is reachable locally again. + fake_vacuum.is_local_connected = True + + # Refresh the coordinator using an arbitrary sensor, which should + # clear the repair issue. + sensor_entity_id = "sensor.roborock_s7_maxv_battery" + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: sensor_entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + # Verify that the repair issue is cleared + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 0 + + # Fake the device is cloud only again. Refreshing the coordinator + # should not recreate the repair issue. + fake_vacuum.is_local_connected = False + + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: sensor_entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + # Verify that the repair issue still does not exist + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 0 + + @pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) async def test_zeo_device_fails_setup( hass: HomeAssistant, diff --git a/tests/components/senz/conftest.py b/tests/components/senz/conftest.py index 1259981277fed..be0bd6e322603 100644 --- a/tests/components/senz/conftest.py +++ b/tests/components/senz/conftest.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import MagicMock, Mock, patch -from aiosenz import Account, Thermostat +from pysenz import Account, Thermostat import pytest from homeassistant.components.application_credentials import ( diff --git a/tests/components/senz/test_config_flow.py b/tests/components/senz/test_config_flow.py index 1892abd454bf0..8d6e8d3b8d6ed 100644 --- a/tests/components/senz/test_config_flow.py +++ b/tests/components/senz/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from aiosenz import AUTHORIZATION_ENDPOINT, TOKEN_ENDPOINT +from pysenz import AUTHORIZATION_ENDPOINT, TOKEN_ENDPOINT import pytest from homeassistant import config_entries @@ -55,7 +55,7 @@ async def test_full_flow( assert result["url"] == ( f"{AUTHORIZATION_ENDPOINT}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" - f"&state={state}&scope=restapi+offline_access" + f"&state={state}&scope=restapi+offline_access+openid" ) client = await hass_client_no_auth() @@ -106,7 +106,7 @@ async def test_duplicate_flow( assert result["url"] == ( f"{AUTHORIZATION_ENDPOINT}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" - f"&state={state}&scope=restapi+offline_access" + f"&state={state}&scope=restapi+offline_access+openid" ) client = await hass_client_no_auth() @@ -177,7 +177,7 @@ async def test_reauth_flow( assert result["url"] == ( f"{AUTHORIZATION_ENDPOINT}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" - f"&state={state}&scope=restapi+offline_access" + f"&state={state}&scope=restapi+offline_access+openid" ) client = await hass_client_no_auth() @@ -254,7 +254,7 @@ async def test_reconfiguration_flow( assert result["url"] == ( f"{AUTHORIZATION_ENDPOINT}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" - f"&state={state}&scope=restapi+offline_access" + f"&state={state}&scope=restapi+offline_access+openid" ) client = await hass_client_no_auth() diff --git a/tests/components/senz/test_init.py b/tests/components/senz/test_init.py index 9908fa3b5c18c..20b9000fd6987 100644 --- a/tests/components/senz/test_init.py +++ b/tests/components/senz/test_init.py @@ -4,8 +4,8 @@ import time from unittest.mock import MagicMock, Mock, patch -from aiosenz import TOKEN_ENDPOINT from httpx import HTTPStatusError, RequestError +from pysenz import TOKEN_ENDPOINT import pytest from homeassistant.components.senz.const import DOMAIN diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index c1b98b2ec60fc..0c655de074928 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -1,22 +1,26 @@ """Tests for the Sonos config flow.""" import asyncio +from http import HTTPStatus import logging from unittest.mock import Mock, PropertyMock, patch from freezegun.api import FrozenDateTimeFactory import pytest +from requests import Response +from requests.exceptions import HTTPError from homeassistant import config_entries from homeassistant.components import sonos from homeassistant.components.sonos.const import ( DISCOVERY_INTERVAL, SONOS_SPEAKER_ACTIVITY, + UPNP_ISSUE_ID, ) from homeassistant.components.sonos.exception import SonosUpdateError from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component @@ -24,7 +28,7 @@ from .conftest import MockSoCo, SoCoMockFactory -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed async def test_creating_entry_sets_up_media_player( @@ -85,6 +89,83 @@ async def test_not_configuring_sonos_not_creates_entry(hass: HomeAssistant) -> N assert len(mock_setup.mock_calls) == 0 +async def test_upnp_disabled_discovery( + hass: HomeAssistant, config_entry: MockConfigEntry, soco: MockSoCo +) -> None: + """Test issue creation when discovery processing fails with 403.""" + + resp = Response() + resp.status_code = HTTPStatus.FORBIDDEN + http_error = HTTPError(response=resp) + + with patch( + "tests.components.sonos.conftest.MockSoCo.household_id", + new_callable=PropertyMock, + create=True, + side_effect=http_error, + ): + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + issue_registry = ir.async_get(hass) + assert ( + issue_registry.async_get_issue( + sonos.DOMAIN, f"{UPNP_ISSUE_ID}_{soco.ip_address}" + ) + is not None + ) + + +async def test_upnp_disabled_manual_hosts( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, +) -> None: + """Test issue creation when manual host processing fails with 403.""" + + resp = Response() + resp.status_code = HTTPStatus.FORBIDDEN + http_error = HTTPError(response=resp) + soco = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Bedroom") + + with patch.object( + type(soco), + "household_id", + new_callable=PropertyMock, + create=True, + side_effect=http_error, + ): + await _setup_hass(hass) + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + sonos.DOMAIN, f"{UPNP_ISSUE_ID}_{soco.ip_address}" + ) + assert issue is not None + assert issue.translation_placeholders.get("device_ip") == "10.10.10.1" + + +async def test_discovery_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test exception handling during discovery processing.""" + + with patch( + "tests.components.sonos.conftest.MockSoCo.household_id", + new_callable=PropertyMock, + create=True, + side_effect=OSError("This is a test"), + ): + caplog.set_level(logging.ERROR) + caplog.clear() + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + assert "This is a test" in caplog.text + + async def test_async_poll_manual_hosts_warnings( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, diff --git a/tests/components/unifiprotect/test_media_source.py b/tests/components/unifiprotect/test_media_source.py index 8b6746f43719e..875932e3e8356 100644 --- a/tests/components/unifiprotect/test_media_source.py +++ b/tests/components/unifiprotect/test_media_source.py @@ -21,6 +21,7 @@ from homeassistant.components.unifiprotect.const import DOMAIN from homeassistant.components.unifiprotect.media_source import ( ProtectMediaSource, + SimpleEventType, async_get_media_source, ) from homeassistant.core import HomeAssistant @@ -1041,3 +1042,66 @@ async def test_browse_media_browse_whole_month_december( assert browse.identifier == base_id assert len(browse.children) == 1 assert browse.children[0].identifier == "test_id:event:test_event_id" + + +@pytest.mark.parametrize( + ("year", "month", "expected_days", "expected_end_month", "expected_end_year"), + [ + (2024, 1, 31, 2, 2024), # January + (2024, 2, 29, 3, 2024), # February (leap year) + (2023, 2, 28, 3, 2023), # February (non-leap year) + (2024, 4, 30, 5, 2024), # April + (2024, 12, 31, 1, 2025), # December - critical edge case + ], +) +async def test_build_days_whole_month_date_calculation( + hass: HomeAssistant, + ufp: MockUFPFixture, + year: int, + month: int, + expected_days: int, + expected_end_month: int, + expected_end_year: int, +) -> None: + """Test that whole month date calculation works for all month types. + + This test verifies the monthrange-based date calculation in _build_days, + especially for December which previously used manual year/month increment logic. + """ + # Initialize the integration entry to get ProtectData + await init_entry(hass, ufp, [], regenerate_ids=False) + + # Create a start date for the first day of the month + start = datetime(year=year, month=month, day=1).date() + start_dt = datetime( + year=start.year, + month=start.month, + day=start.day, + hour=0, + minute=0, + second=0, + tzinfo=dt_util.get_default_time_zone(), + ) + + # Verify we got the expected number of days + expected_end = start_dt + timedelta(days=expected_days) + + # Verify it correctly goes to the expected month/year + assert expected_end.month == expected_end_month + assert expected_end.year == expected_end_year + assert expected_end.day == 1 + + # Build the media source with is_all=True (whole month) + source = ProtectMediaSource(hass, {}) + result = await source._build_days( + data=ufp.entry.runtime_data, + camera_id="test_camera", + event_type=SimpleEventType.ALL, + start=start, + is_all=True, + build_children=False, # We only care about the identifier, not children + ) + + # Verify the identifier format is correct + assert result.identifier.endswith(f"range:{year}:{month}:all") + assert "Whole Month" in result.title diff --git a/tests/components/wled/conftest.py b/tests/components/wled/conftest.py index 301729843a224..32acd40c85342 100644 --- a/tests/components/wled/conftest.py +++ b/tests/components/wled/conftest.py @@ -22,6 +22,7 @@ def mock_config_entry() -> MockConfigEntry: domain=DOMAIN, data={CONF_HOST: "192.168.1.123"}, unique_id="aabbccddeeff", + minor_version=2, ) diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index 984b511be7a11..1b6aea0543139 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -281,12 +281,15 @@ async def test_zeroconf_unsupported_version_error( @pytest.mark.usefixtures("mock_wled") +@pytest.mark.parametrize("device_mac", ["aabbccddeeff", "AABBCCDDEEFF"]) async def test_user_device_exists_abort( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wled: MagicMock, + device_mac: str, ) -> None: """Test we abort zeroconf flow if WLED device already configured.""" + mock_wled.update.return_value.info.mac_address = device_mac mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -323,10 +326,12 @@ async def test_zeroconf_without_mac_device_exists_abort( assert result.get("reason") == "already_configured" +@pytest.mark.parametrize("device_mac", ["aabbccddeeff", "AABBCCDDEEFF"]) async def test_zeroconf_with_mac_device_exists_abort( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wled: MagicMock, + device_mac: str, ) -> None: """Test we abort zeroconf flow if WLED device already configured.""" mock_config_entry.add_to_hass(hass) @@ -339,7 +344,7 @@ async def test_zeroconf_with_mac_device_exists_abort( hostname="example.local.", name="mock_name", port=None, - properties={CONF_MAC: "aabbccddeeff"}, + properties={CONF_MAC: device_mac}, type="mock_type", ), ) diff --git a/tests/components/wled/test_init.py b/tests/components/wled/test_init.py index 9dfcabd55e3f8..25d2ee3530d2c 100644 --- a/tests/components/wled/test_init.py +++ b/tests/components/wled/test_init.py @@ -7,7 +7,9 @@ import pytest from wled import WLEDConnectionError -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.wled.const import DOMAIN +from homeassistant.config_entries import SOURCE_IGNORE, ConfigEntryState +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -61,9 +63,151 @@ async def test_config_entry_not_ready( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_setting_unique_id( - hass: HomeAssistant, init_integration: MockConfigEntry +@pytest.fixture +def config_entry_v1() -> MockConfigEntry: + """Return a WLED config entry at version 1.0 with a specific MAC.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.123"}, + unique_id="AABBCCDDEEFF", + minor_version=1, + ) + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_wled") +async def test_migrate_entry_future_version_is_downgrade( + hass: HomeAssistant, +) -> None: + """Return False when user downgraded from a future version.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="WLED Future", + unique_id="AABBCCDDEEFF", + version=2, + minor_version=0, + data={CONF_HOST: "wled.local"}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert result is False + assert entry.state == ConfigEntryState.MIGRATION_ERROR + assert entry.version == 2 + assert entry.minor_version == 0 + assert entry.unique_id == "AABBCCDDEEFF" + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_wled") +async def test_migrate_entry_v1_to_1_2_no_duplicates( + hass: HomeAssistant, config_entry_v1: MockConfigEntry +) -> None: + """Migrate from 1.x to 1.2 when there are no other entries with same MAC.""" + config_entry_v1.add_to_hass(hass) + + result = await hass.config_entries.async_setup(config_entry_v1.entry_id) + await hass.async_block_till_done() + + assert result is True + assert config_entry_v1.state == ConfigEntryState.LOADED + assert config_entry_v1.version == 1 + assert config_entry_v1.minor_version == 2 + assert config_entry_v1.unique_id == "aabbccddeeff" + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_wled") +async def test_migrate_entry_v1_with_ignored_duplicates( + hass: HomeAssistant, config_entry_v1: MockConfigEntry ) -> None: - """Test we set unique ID if not set yet.""" - assert init_integration.runtime_data - assert init_integration.unique_id == "aabbccddeeff" + """Remove ignored entries with the same MAC and then migrate.""" + config_entry_v1.add_to_hass(hass) + + ignored_1 = MockConfigEntry( + domain=DOMAIN, + title="Ignored 1", + unique_id="aabbccddeeff", + source=SOURCE_IGNORE, + version=1, + minor_version=0, + data={"host": "wled-ignored-1.local"}, + ) + ignored_2 = MockConfigEntry( + domain=DOMAIN, + title="Ignored 2", + unique_id="aabbccddeeff", + source=SOURCE_IGNORE, + version=1, + minor_version=0, + data={"host": "wled-ignored-2.local"}, + ) + + ignored_1.add_to_hass(hass) + ignored_2.add_to_hass(hass) + + result = await hass.config_entries.async_setup(config_entry_v1.entry_id) + await hass.async_block_till_done() + + assert result is True + assert config_entry_v1.state == ConfigEntryState.LOADED + assert config_entry_v1.version == 1 + assert config_entry_v1.minor_version == 2 + assert config_entry_v1.unique_id == "aabbccddeeff" + + assert ignored_1.state is ConfigEntryState.NOT_LOADED + assert ignored_2.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_wled") +async def test_migrate_entry_v1_with_non_ignored_duplicate_aborts( + hass: HomeAssistant, + config_entry_v1: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Abort migration when there is another non-ignored entry with the same MAC.""" + config_entry_v1.add_to_hass(hass) + + duplicate_active = MockConfigEntry( + domain=DOMAIN, + title="Active duplicate", + unique_id="aabbccddeeff", + version=1, + minor_version=0, + data={"host": "wled-duplicate.local"}, + ) + duplicate_active.add_to_hass(hass) + + result = await hass.config_entries.async_setup(config_entry_v1.entry_id) + await hass.async_block_till_done() + + assert result is False + assert config_entry_v1.state == ConfigEntryState.MIGRATION_ERROR + assert config_entry_v1.version == 1 + assert config_entry_v1.minor_version == 1 + assert config_entry_v1.unique_id == "AABBCCDDEEFF" + assert "multiple WLED config entries with the same MAC address" in caplog.text + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_wled") +async def test_migrate_entry_already_at_1_2_is_noop( + hass: HomeAssistant, +) -> None: + """Do nothing when entry is already at version 1.2.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="WLED Already 1.2", + unique_id="aabbccddeeff", + version=1, + minor_version=2, + data={"host": "wled.local"}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert result is True + assert entry.state == ConfigEntryState.LOADED + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.unique_id == "aabbccddeeff" diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 345c0bbfd51df..95d970d14d798 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -1258,6 +1258,58 @@ def test_unit_conversion_factory_allow_none_with_none() -> None: )(None) is None ) + assert ( + EnergyDistanceConverter.converter_factory_allow_none( + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + )(0) + is None + ) + assert ( + EnergyDistanceConverter.converter_factory_allow_none( + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + UnitOfEnergyDistance.WATT_HOUR_PER_KM, + )(0) + == 0 + ) + assert ( + EnergyDistanceConverter.converter_factory_allow_none( + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + )(0.0) + == 0.0 + ) + assert ( + EnergyDistanceConverter.converter_factory_allow_none( + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + )(0) + == 0.0 + ) + + +def test_unit_conversion_factory_allow_none_with_zero_for_inverse_units() -> None: + """Test converter_factory_allow_none returns None for zero with inverse units.""" + # Test EnergyDistanceConverter with inverse units (kWh/100km <-> km/kWh) + assert ( + EnergyDistanceConverter.converter_factory_allow_none( + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + )(0) + is None + ) + assert ( + EnergyDistanceConverter.converter_factory_allow_none( + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + )(0) + is None + ) + # Test with non-zero value to ensure normal conversion still works + assert EnergyDistanceConverter.converter_factory_allow_none( + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + )(25) == pytest.approx(4) @pytest.mark.parametrize(