This commit is contained in:
Bram Kragten 2025-01-09 23:25:42 +01:00 committed by GitHub
commit bceccd85ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 175 additions and 56 deletions

View File

@ -7,6 +7,7 @@ from collections.abc import Callable
from dataclasses import dataclass, field, replace
from datetime import datetime, timedelta
from enum import StrEnum
import random
from typing import TYPE_CHECKING, Self, TypedDict
from cronsim import CronSim
@ -28,6 +29,10 @@ if TYPE_CHECKING:
CRON_PATTERN_DAILY = "45 4 * * *"
CRON_PATTERN_WEEKLY = "45 4 * * {}"
# Randomize the start time of the backup by up to 60 minutes to avoid
# all backups running at the same time.
BACKUP_START_TIME_JITTER = 60 * 60
class StoredBackupConfig(TypedDict):
"""Represent the stored backup config."""
@ -329,6 +334,8 @@ class BackupSchedule:
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected error creating automatic backup")
next_time += timedelta(seconds=random.randint(0, BACKUP_START_TIME_JITTER))
LOGGER.debug("Scheduling next automatic backup at %s", next_time)
manager.remove_next_backup_event = async_track_point_in_time(
manager.hass, _create_backup, next_time
)

View File

@ -2,10 +2,12 @@
from __future__ import annotations
import asyncio
import base64
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
import hashlib
import logging
import random
from typing import Any, Self
from aiohttp import ClientError, ClientTimeout, StreamReader
@ -26,6 +28,9 @@ from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT
_LOGGER = logging.getLogger(__name__)
_STORAGE_BACKUP = "backup"
_RETRY_LIMIT = 5
_RETRY_SECONDS_MIN = 60
_RETRY_SECONDS_MAX = 600
async def _b64md5(stream: AsyncIterator[bytes]) -> str:
@ -138,13 +143,55 @@ class CloudBackupAgent(BackupAgent):
raise BackupAgentError("Failed to get download details") from err
try:
resp = await self._cloud.websession.get(details["url"])
resp = await self._cloud.websession.get(
details["url"],
timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h
)
resp.raise_for_status()
except ClientError as err:
raise BackupAgentError("Failed to download backup") from err
return ChunkAsyncStreamIterator(resp.content)
async def _async_do_upload_backup(
self,
*,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
filename: str,
base64md5hash: str,
metadata: dict[str, Any],
size: int,
) -> None:
"""Upload a backup."""
try:
details = await async_files_upload_details(
self._cloud,
storage_type=_STORAGE_BACKUP,
filename=filename,
metadata=metadata,
size=size,
base64md5hash=base64md5hash,
)
except (ClientError, CloudError) as err:
raise BackupAgentError("Failed to get upload details") from err
try:
upload_status = await self._cloud.websession.put(
details["url"],
data=await open_stream(),
headers=details["headers"] | {"content-length": str(size)},
timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h
)
_LOGGER.log(
logging.DEBUG if upload_status.status < 400 else logging.WARNING,
"Backup upload status: %s",
upload_status.status,
)
upload_status.raise_for_status()
except (TimeoutError, ClientError) as err:
raise BackupAgentError("Failed to upload backup") from err
async def async_upload_backup(
self,
*,
@ -161,34 +208,34 @@ class CloudBackupAgent(BackupAgent):
raise BackupAgentError("Cloud backups must be protected")
base64md5hash = await _b64md5(await open_stream())
filename = self._get_backup_filename()
metadata = backup.as_dict()
size = backup.size
try:
details = await async_files_upload_details(
self._cloud,
storage_type=_STORAGE_BACKUP,
filename=self._get_backup_filename(),
metadata=backup.as_dict(),
size=backup.size,
base64md5hash=base64md5hash,
)
except (ClientError, CloudError) as err:
raise BackupAgentError("Failed to get upload details") from err
try:
upload_status = await self._cloud.websession.put(
details["url"],
data=await open_stream(),
headers=details["headers"] | {"content-length": str(backup.size)},
timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h
)
_LOGGER.log(
logging.DEBUG if upload_status.status < 400 else logging.WARNING,
"Backup upload status: %s",
upload_status.status,
)
upload_status.raise_for_status()
except (TimeoutError, ClientError) as err:
raise BackupAgentError("Failed to upload backup") from err
tries = 1
while tries <= _RETRY_LIMIT:
try:
await self._async_do_upload_backup(
open_stream=open_stream,
filename=filename,
base64md5hash=base64md5hash,
metadata=metadata,
size=size,
)
break
except BackupAgentError as err:
if tries == _RETRY_LIMIT:
raise
tries += 1
retry_timer = random.randint(_RETRY_SECONDS_MIN, _RETRY_SECONDS_MAX)
_LOGGER.info(
"Failed to upload backup, retrying (%s/%s) in %ss: %s",
tries,
_RETRY_LIMIT,
retry_timer,
err,
)
await asyncio.sleep(retry_timer)
async def async_delete_backup(
self,

View File

@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["cookidoo_api"],
"quality_scale": "silver",
"requirements": ["cookidoo-api==0.11.2"]
"requirements": ["cookidoo-api==0.12.2"]
}

View File

@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyflick"],
"requirements": ["PyFlick==1.1.2"]
"requirements": ["PyFlick==1.1.3"]
}

View File

@ -51,19 +51,19 @@ class FlickPricingSensor(CoordinatorEntity[FlickElectricDataCoordinator], Sensor
_LOGGER.warning(
"Unexpected quantity for unit price: %s", self.coordinator.data
)
return self.coordinator.data.cost
return self.coordinator.data.cost * 100
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes."""
components: dict[str, Decimal] = {}
components: dict[str, float] = {}
for component in self.coordinator.data.components:
if component.charge_setter not in ATTR_COMPONENTS:
_LOGGER.warning("Found unknown component: %s", component.charge_setter)
continue
components[component.charge_setter] = component.value
components[component.charge_setter] = float(component.value * 100)
return {
ATTR_START_AT: self.coordinator.data.start_at,

View File

@ -21,5 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250106.0"]
"requirements": ["home-assistant-frontend==20250109.0"]
}

View File

@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
"requirements": ["aioautomower==2024.12.0"]
"requirements": ["aioautomower==2025.1.0"]
}

View File

@ -6,6 +6,7 @@ import logging
from meteofrance_api.client import MeteoFranceClient
from meteofrance_api.helpers import is_valid_warning_department
from meteofrance_api.model import CurrentPhenomenons, Forecast, Rain
from requests import RequestException
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@ -83,7 +84,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
update_method=_async_update_data_rain,
update_interval=SCAN_INTERVAL_RAIN,
)
await coordinator_rain.async_config_entry_first_refresh()
try:
await coordinator_rain._async_refresh(log_failures=False) # noqa: SLF001
except RequestException:
_LOGGER.warning(
"1 hour rain forecast not available: %s is not in covered zone",
entry.title,
)
department = coordinator_forecast.data.position.get("dept")
_LOGGER.debug(
@ -128,8 +135,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[DOMAIN][entry.entry_id] = {
UNDO_UPDATE_LISTENER: undo_listener,
COORDINATOR_FORECAST: coordinator_forecast,
COORDINATOR_RAIN: coordinator_rain,
}
if coordinator_rain and coordinator_rain.last_update_success:
hass.data[DOMAIN][entry.entry_id][COORDINATOR_RAIN] = coordinator_rain
if coordinator_alert and coordinator_alert.last_update_success:
hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT] = coordinator_alert

View File

@ -187,7 +187,7 @@ async def async_setup_entry(
"""Set up the Meteo-France sensor platform."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator_forecast: DataUpdateCoordinator[Forecast] = data[COORDINATOR_FORECAST]
coordinator_rain: DataUpdateCoordinator[Rain] | None = data[COORDINATOR_RAIN]
coordinator_rain: DataUpdateCoordinator[Rain] | None = data.get(COORDINATOR_RAIN)
coordinator_alert: DataUpdateCoordinator[CurrentPhenomenons] | None = data.get(
COORDINATOR_ALERT
)

View File

@ -6,7 +6,7 @@ from typing import Any, cast
from urllib.parse import urlparse
from pyoverkiz.enums import OverkizCommand, Protocol
from pyoverkiz.exceptions import OverkizException
from pyoverkiz.exceptions import BaseOverkizException
from pyoverkiz.models import Command, Device, StateDefinition
from pyoverkiz.types import StateType as OverkizStateType
@ -105,7 +105,7 @@ class OverkizExecutor:
"Home Assistant",
)
# Catch Overkiz exceptions to support `continue_on_error` functionality
except OverkizException as exception:
except BaseOverkizException as exception:
raise HomeAssistantError(exception) from exception
# ExecutionRegisteredEvent doesn't contain the device_url, thus we need to register it here

View File

@ -82,7 +82,8 @@ def get_device_uid_and_ch(
ch = int(device_uid[1][5:])
is_chime = True
else:
ch = host.api.channel_for_uid(device_uid[1])
device_uid_part = "_".join(device_uid[1:])
ch = host.api.channel_for_uid(device_uid_part)
return (device_uid, ch, is_chime)

View File

@ -7,5 +7,5 @@
"iot_class": "cloud_polling",
"loggers": ["pysuez", "regex"],
"quality_scale": "bronze",
"requirements": ["pysuezV2==2.0.1"]
"requirements": ["pysuezV2==2.0.3"]
}

View File

@ -87,7 +87,7 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity):
manufacturer=zha_device_info[ATTR_MANUFACTURER],
model=zha_device_info[ATTR_MODEL],
name=zha_device_info[ATTR_NAME],
via_device=(DOMAIN, zha_gateway.state.node_info.ieee),
via_device=(DOMAIN, str(zha_gateway.state.node_info.ieee)),
)
@callback

View File

@ -25,7 +25,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2025
MINOR_VERSION: Final = 1
PATCH_VERSION: Final = "1"
PATCH_VERSION: Final = "2"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0)

View File

@ -35,7 +35,7 @@ habluetooth==3.7.0
hass-nabucasa==0.87.0
hassil==2.1.0
home-assistant-bluetooth==1.13.0
home-assistant-frontend==20250106.0
home-assistant-frontend==20250109.0
home-assistant-intents==2025.1.1
httpx==0.27.2
ifaddr==0.2.0

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2025.1.1"
version = "2025.1.2"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"

View File

@ -48,7 +48,7 @@ ProgettiHWSW==0.1.3
PyChromecast==14.0.5
# homeassistant.components.flick_electric
PyFlick==1.1.2
PyFlick==1.1.3
# homeassistant.components.flume
PyFlume==0.6.5
@ -201,7 +201,7 @@ aioaseko==1.0.0
aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower
aioautomower==2024.12.0
aioautomower==2025.1.0
# homeassistant.components.azure_devops
aioazuredevops==2.2.1
@ -704,7 +704,7 @@ connect-box==0.3.1
construct==2.10.68
# homeassistant.components.cookidoo
cookidoo-api==0.11.2
cookidoo-api==0.12.2
# homeassistant.components.backup
# homeassistant.components.utility_meter
@ -1134,7 +1134,7 @@ hole==0.8.0
holidays==0.64
# homeassistant.components.frontend
home-assistant-frontend==20250106.0
home-assistant-frontend==20250109.0
# homeassistant.components.conversation
home-assistant-intents==2025.1.1
@ -2309,7 +2309,7 @@ pysqueezebox==0.10.0
pystiebeleltron==0.0.1.dev2
# homeassistant.components.suez_water
pysuezV2==2.0.1
pysuezV2==2.0.3
# homeassistant.components.switchbee
pyswitchbee==1.8.3

View File

@ -45,7 +45,7 @@ ProgettiHWSW==0.1.3
PyChromecast==14.0.5
# homeassistant.components.flick_electric
PyFlick==1.1.2
PyFlick==1.1.3
# homeassistant.components.flume
PyFlume==0.6.5
@ -189,7 +189,7 @@ aioaseko==1.0.0
aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower
aioautomower==2024.12.0
aioautomower==2025.1.0
# homeassistant.components.azure_devops
aioazuredevops==2.2.1
@ -600,7 +600,7 @@ colorthief==0.2.1
construct==2.10.68
# homeassistant.components.cookidoo
cookidoo-api==0.11.2
cookidoo-api==0.12.2
# homeassistant.components.backup
# homeassistant.components.utility_meter
@ -963,7 +963,7 @@ hole==0.8.0
holidays==0.64
# homeassistant.components.frontend
home-assistant-frontend==20250106.0
home-assistant-frontend==20250109.0
# homeassistant.components.conversation
home-assistant-intents==2025.1.1
@ -1875,7 +1875,7 @@ pyspeex-noise==1.0.2
pysqueezebox==0.10.0
# homeassistant.components.suez_water
pysuezV2==2.0.1
pysuezV2==2.0.3
# homeassistant.components.switchbee
pyswitchbee==1.8.3

View File

@ -1345,6 +1345,7 @@ async def test_config_update_errors(
),
],
)
@patch("homeassistant.components.backup.config.BACKUP_START_TIME_JITTER", 0)
async def test_config_schedule_logic(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
@ -1787,6 +1788,7 @@ async def test_config_schedule_logic(
),
],
)
@patch("homeassistant.components.backup.config.BACKUP_START_TIME_JITTER", 0)
async def test_config_retention_copies_logic(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,

View File

@ -389,6 +389,7 @@ async def test_agents_upload_fail_put(
aioclient_mock: AiohttpClientMocker,
mock_get_upload_details: Mock,
put_mock_kwargs: dict[str, Any],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test agent upload backup fails."""
client = await hass_client()
@ -417,6 +418,9 @@ async def test_agents_upload_fail_put(
return_value=test_backup,
),
patch("pathlib.Path.open") as mocked_open,
patch("homeassistant.components.cloud.backup.asyncio.sleep"),
patch("homeassistant.components.cloud.backup.random.randint", return_value=60),
patch("homeassistant.components.cloud.backup._RETRY_LIMIT", 2),
):
mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
fetch_backup.return_value = test_backup
@ -426,6 +430,8 @@ async def test_agents_upload_fail_put(
)
await hass.async_block_till_done()
assert len(aioclient_mock.mock_calls) == 2
assert "Failed to upload backup, retrying (2/2) in 60s" in caplog.text
assert resp.status == 201
store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"]
assert len(store_backups) == 1
@ -469,6 +475,7 @@ async def test_agents_upload_fail_cloud(
return_value=test_backup,
),
patch("pathlib.Path.open") as mocked_open,
patch("homeassistant.components.cloud.backup.asyncio.sleep"),
):
mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
fetch_backup.return_value = test_backup

View File

@ -0,0 +1,47 @@
"""Test ZHA entities."""
from zigpy.profiles import zha
from zigpy.zcl.clusters import general
from homeassistant.components.zha.helpers import get_zha_gateway
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
async def test_device_registry_via_device(
hass: HomeAssistant,
setup_zha,
zigpy_device_mock,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test ZHA `via_device` is set correctly."""
await setup_zha()
gateway = get_zha_gateway(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [general.Basic.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
)
zha_device = gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
reg_coordinator_device = device_registry.async_get_device(
identifiers={("zha", str(gateway.state.node_info.ieee))}
)
reg_device = device_registry.async_get_device(
identifiers={("zha", str(zha_device.ieee))}
)
assert reg_device.via_device_id == reg_coordinator_device.id