This commit is contained in:
Franck Nijhof
2025-12-29 13:53:33 +01:00
committed by GitHub
51 changed files with 745 additions and 117 deletions
@@ -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"]
}
+1 -1
View File
@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==65"],
"requirements": ["axis==66"],
"ssdp": [
{
"manufacturer": "AXIS"
+1 -1
View File
@@ -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,
)
@@ -35,7 +35,7 @@
"cpu_overheating": "CPU overheating",
"none": "None",
"pellets": "Pellets",
"unkownn": "Unknown alarm"
"unknown": "Unknown alarm"
}
},
"convector_air_flow": {
@@ -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,
)
@@ -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:
@@ -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": [
@@ -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"]
}
+6 -1
View File
@@ -4,7 +4,11 @@ from __future__ import annotations
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
@@ -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(
@@ -111,6 +111,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState]):
# 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 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState]):
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 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState]):
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()
@@ -13,7 +13,22 @@ from .coordinator import RoborockConfigEntry
_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(
@@ -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"
]
}
+2 -4
View File
@@ -92,10 +92,8 @@ class SamsungTVEntity(CoordinatorEntity[SamsungTVDataUpdateCoordinator], Entity)
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)
+1 -1
View File
@@ -6,9 +6,9 @@ from datetime import timedelta
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
+1 -1
View File
@@ -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
@@ -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
+1 -1
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
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,
+1 -1
View File
@@ -33,7 +33,7 @@ class OAuth2FlowHandler(
@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]
+2 -2
View File
@@ -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"]
}
+1 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from aiosenz import Thermostat
from pysenz import Thermostat
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -7,6 +7,7 @@ from datetime import timedelta
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.const import CONF_HOST
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 @@ class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]):
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:
+56 -8
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
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 typing import Any, cast
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 @@ from .const import (
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 @@ class SonosDiscoveryManager:
"""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 @@ class SonosDiscoveryManager:
)
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 @@ class SonosDiscoveryManager:
sync_get_visible_zones,
soco,
)
except HTTPError as err:
await self._process_http_connection_error(err, ip_addr)
continue
except (
OSError,
SoCoException,
+3
View File
@@ -20,6 +20,9 @@ PLATFORMS = [
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"
@@ -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": {
@@ -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",
@@ -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
if seconds > 0:
formatted += f"{seconds}s "
hours, seconds = divmod(seconds, 3600)
minutes, seconds = divmod(seconds, 60)
return formatted.strip()
parts = []
if hours > 0:
parts.append(f"{hours}h")
if minutes > 0:
parts.append(f"{minutes}m")
if seconds > 0:
parts.append(f"{seconds}s")
return " ".join(parts) if parts else "0s"
@callback
@@ -593,7 +594,8 @@ class ProtectMediaSource(MediaSource):
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 @@ class ProtectMediaSource(MediaSource):
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 @@ class ProtectMediaSource(MediaSource):
]
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)
@@ -14,7 +14,7 @@
"velbus-protocol"
],
"quality_scale": "bronze",
"requirements": ["velbus-aio==2025.11.0"],
"requirements": ["velbus-aio==2025.12.0"],
"usb": [
{
"pid": "0B1B",
+74
View File
@@ -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 @@ from .coordinator import (
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
+10 -7
View File
@@ -21,13 +21,14 @@ from homeassistant.helpers.device_registry import format_mac
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 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN):
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 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN):
"""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 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN):
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(
+21 -3
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from wled import (
WLED,
Device as WLEDDevice,
@@ -32,6 +34,17 @@ from .const import (
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 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]):
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 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[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(),
},
)
+1 -1
View File
@@ -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"]
}
@@ -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"]
}
+1 -1
View File
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
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)
+5 -1
View File
@@ -154,7 +154,11 @@ class BaseUnitConverter:
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
+1 -1
View File
@@ -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."
+10 -10
View File
@@ -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
+10 -10
View File
@@ -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
+1
View File
@@ -181,6 +181,7 @@ EXCEPTIONS = {
"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
@@ -4,7 +4,9 @@
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -39,6 +41,7 @@
'attributes': ReadOnlyDict({
'device_class': 'battery',
'friendly_name': 'Home Battery',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
@@ -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',
+95
View File
@@ -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,
+1 -1
View File
@@ -5,7 +5,7 @@ import time
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 (
+5 -5
View File
@@ -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()
+1 -1
View File
@@ -4,8 +4,8 @@ from http import HTTPStatus
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
+83 -2
View File
@@ -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 homeassistant.util import dt as dt_util
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,
@@ -21,6 +21,7 @@ from homeassistant.components.media_source import MediaSourceItem
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
+1
View File
@@ -22,6 +22,7 @@ def mock_config_entry() -> MockConfigEntry:
domain=DOMAIN,
data={CONF_HOST: "192.168.1.123"},
unique_id="aabbccddeeff",
minor_version=2,
)
+6 -1
View File
@@ -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",
),
)
+150 -6
View File
@@ -7,7 +7,9 @@ from unittest.mock import AsyncMock, MagicMock, patch
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:
"""Test we set unique ID if not set yet."""
assert init_integration.runtime_data
assert init_integration.unique_id == "aabbccddeeff"
"""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:
"""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"
+52
View File
@@ -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(