mirror of
https://github.com/home-assistant/core.git
synced 2026-09-04 18:15:04 -05:00
2025.12.5 (#159934)
This commit is contained in:
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["axis"],
|
||||
"requirements": ["axis==65"],
|
||||
"requirements": ["axis==66"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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."
|
||||
|
||||
Generated
+10
-10
@@ -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
|
||||
|
||||
Generated
+10
-10
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,6 +22,7 @@ def mock_config_entry() -> MockConfigEntry:
|
||||
domain=DOMAIN,
|
||||
data={CONF_HOST: "192.168.1.123"},
|
||||
unique_id="aabbccddeeff",
|
||||
minor_version=2,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user