mirror of
https://github.com/home-assistant/core.git
synced 2026-02-23 10:41:19 +01:00
Compare commits
89 Commits
tibber_ref
...
2026.2.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c640fe0fa | ||
|
|
62145e5f9e | ||
|
|
c0fc414bb9 | ||
|
|
69411a05ff | ||
|
|
06c9ec861d | ||
|
|
946df1755f | ||
|
|
d0678e0641 | ||
|
|
ec56f183da | ||
|
|
033005e0de | ||
|
|
91f9f5a826 | ||
|
|
ac4fcab827 | ||
|
|
d0eea77178 | ||
|
|
fb38fa3844 | ||
|
|
440efb953e | ||
|
|
7ce47cca0d | ||
|
|
a5f607bb91 | ||
|
|
b03043aa6f | ||
|
|
0f3c7ca277 | ||
|
|
3abf7c22f3 | ||
|
|
292e1de126 | ||
|
|
2d776a8193 | ||
|
|
039bbbb48c | ||
|
|
ad5565df95 | ||
|
|
3e6bc29a6a | ||
|
|
ec8067a5a8 | ||
|
|
6f47716d0a | ||
|
|
efba5c6bcc | ||
|
|
d10e78079f | ||
|
|
6d4581580f | ||
|
|
0d9a41a540 | ||
|
|
cd69e6db73 | ||
|
|
1320367d0d | ||
|
|
dfa4698887 | ||
|
|
b426115de7 | ||
|
|
fb79fa37f8 | ||
|
|
6a5f7bf424 | ||
|
|
142ca6dec1 | ||
|
|
0f986c24d0 | ||
|
|
01f2b7b6f6 | ||
|
|
b9469027f5 | ||
|
|
fbb94af748 | ||
|
|
148bdf6e3a | ||
|
|
91999f8871 | ||
|
|
aecca4eb99 | ||
|
|
bf8aa49bae | ||
|
|
4423425683 | ||
|
|
44202da53d | ||
|
|
9f7dfb72c4 | ||
|
|
de07a69e4f | ||
|
|
bbf4c38115 | ||
|
|
e1bb5d52ef | ||
|
|
eb64b6bdee | ||
|
|
ecb288b735 | ||
|
|
a419c9c420 | ||
|
|
dd29133324 | ||
|
|
90f22ea516 | ||
|
|
9db1428265 | ||
|
|
a696b05b0d | ||
|
|
77ddb63b73 | ||
|
|
4180a6e176 | ||
|
|
6d74c912d2 | ||
|
|
8a01dfcc00 | ||
|
|
9722898dc6 | ||
|
|
7438c71fcb | ||
|
|
0b5e55b923 | ||
|
|
61ed959e8e | ||
|
|
3989532465 | ||
|
|
28027ddca4 | ||
|
|
fe0d7b3cca | ||
|
|
0dcc4e9527 | ||
|
|
b13b189703 | ||
|
|
150829f599 | ||
|
|
57dd9d9c23 | ||
|
|
e2056cb12c | ||
|
|
fa2c8992cf | ||
|
|
ddf5c7fe3a | ||
|
|
7034ed6d3f | ||
|
|
9015b53c1b | ||
|
|
1cfa6561f7 | ||
|
|
eead02dcca | ||
|
|
456e51a221 | ||
|
|
5d984ce186 | ||
|
|
61f45489ac | ||
|
|
f72c643b38 | ||
|
|
27bc26e886 | ||
|
|
0e9f03cbc1 | ||
|
|
9480c33fb0 | ||
|
|
3e6b8663e8 | ||
|
|
1c69a83793 |
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -37,7 +37,7 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CACHE_VERSION: 2
|
||||
CACHE_VERSION: 3
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.2"
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==11.1.1"]
|
||||
"requirements": ["aioamazondevices==11.1.3"]
|
||||
}
|
||||
|
||||
@@ -26,10 +26,9 @@ from homeassistant.const import (
|
||||
UnitOfVolumetricFlux,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AmbientStation, AmbientStationConfigEntry
|
||||
from . import AmbientStationConfigEntry
|
||||
from .const import ATTR_LAST_DATA, TYPE_SOLARRADIATION, TYPE_SOLARRADIATION_LX
|
||||
from .entity import AmbientWeatherEntity
|
||||
|
||||
@@ -683,22 +682,6 @@ async def async_setup_entry(
|
||||
class AmbientWeatherSensor(AmbientWeatherEntity, SensorEntity):
|
||||
"""Define an Ambient sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ambient: AmbientStation,
|
||||
mac_address: str,
|
||||
station_name: str,
|
||||
description: EntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(ambient, mac_address, station_name, description)
|
||||
|
||||
if description.key == TYPE_SOLARRADIATION_LX:
|
||||
# Since TYPE_SOLARRADIATION and TYPE_SOLARRADIATION_LX will have the same
|
||||
# name in the UI, we influence the entity ID of TYPE_SOLARRADIATION_LX here
|
||||
# to differentiate them:
|
||||
self.entity_id = f"sensor.{station_name}_solar_rad_lx"
|
||||
|
||||
@callback
|
||||
def update_from_latest_data(self) -> None:
|
||||
"""Fetch new state data for the sensor."""
|
||||
|
||||
@@ -16,12 +16,18 @@ CONNECTION_TIMEOUT = 120 # 2 minutes
|
||||
# Default TIMEOUT_FOR_UPLOAD is 128 seconds, which is too short for large backups
|
||||
TIMEOUT_FOR_UPLOAD = 43200 # 12 hours
|
||||
|
||||
# Reduced retry count for download operations
|
||||
# Default is 20 retries with exponential backoff, which can hang for 30+ minutes
|
||||
# when there are persistent connection errors (e.g., SSL failures)
|
||||
TRY_COUNT_DOWNLOAD = 3
|
||||
|
||||
|
||||
class B2Http(BaseB2Http): # type: ignore[misc]
|
||||
"""B2Http with extended timeouts for backup operations."""
|
||||
|
||||
CONNECTION_TIMEOUT = CONNECTION_TIMEOUT
|
||||
TIMEOUT_FOR_UPLOAD = TIMEOUT_FOR_UPLOAD
|
||||
TRY_COUNT_DOWNLOAD = TRY_COUNT_DOWNLOAD
|
||||
|
||||
|
||||
class B2Session(BaseB2Session): # type: ignore[misc]
|
||||
|
||||
@@ -40,6 +40,10 @@ CACHE_TTL = 300
|
||||
# This prevents uploads from hanging indefinitely
|
||||
UPLOAD_TIMEOUT = 43200 # 12 hours (matches B2 HTTP timeout)
|
||||
|
||||
# Timeout for metadata download operations (in seconds)
|
||||
# This prevents the backup system from hanging when B2 connections fail
|
||||
METADATA_DOWNLOAD_TIMEOUT = 60
|
||||
|
||||
|
||||
def suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
|
||||
"""Return the suggested filenames for the backup and metadata files."""
|
||||
@@ -413,12 +417,21 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
backups = {}
|
||||
for file_name, file_version in all_files_in_prefix.items():
|
||||
if file_name.endswith(METADATA_FILE_SUFFIX):
|
||||
backup = await self._hass.async_add_executor_job(
|
||||
self._process_metadata_file_sync,
|
||||
file_name,
|
||||
file_version,
|
||||
all_files_in_prefix,
|
||||
)
|
||||
try:
|
||||
backup = await asyncio.wait_for(
|
||||
self._hass.async_add_executor_job(
|
||||
self._process_metadata_file_sync,
|
||||
file_name,
|
||||
file_version,
|
||||
all_files_in_prefix,
|
||||
),
|
||||
timeout=METADATA_DOWNLOAD_TIMEOUT,
|
||||
)
|
||||
except TimeoutError:
|
||||
_LOGGER.warning(
|
||||
"Timeout downloading metadata file %s", file_name
|
||||
)
|
||||
continue
|
||||
if backup:
|
||||
backups[backup.backup_id] = backup
|
||||
self._backup_list_cache = backups
|
||||
@@ -442,10 +455,18 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
if not file or not metadata_file_version:
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
|
||||
metadata_content = await self._hass.async_add_executor_job(
|
||||
self._download_and_parse_metadata_sync,
|
||||
metadata_file_version,
|
||||
)
|
||||
try:
|
||||
metadata_content = await asyncio.wait_for(
|
||||
self._hass.async_add_executor_job(
|
||||
self._download_and_parse_metadata_sync,
|
||||
metadata_file_version,
|
||||
),
|
||||
timeout=METADATA_DOWNLOAD_TIMEOUT,
|
||||
)
|
||||
except TimeoutError:
|
||||
raise BackupAgentError(
|
||||
f"Timeout downloading metadata for backup {backup_id}"
|
||||
) from None
|
||||
|
||||
_LOGGER.debug(
|
||||
"Successfully retrieved metadata for backup ID %s from file %s",
|
||||
@@ -468,16 +489,27 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
# Process metadata files sequentially to avoid exhausting executor pool
|
||||
for file_name, file_version in all_files_in_prefix.items():
|
||||
if file_name.endswith(METADATA_FILE_SUFFIX):
|
||||
(
|
||||
result_backup_file,
|
||||
result_metadata_file_version,
|
||||
) = await self._hass.async_add_executor_job(
|
||||
self._process_metadata_file_for_id_sync,
|
||||
file_name,
|
||||
file_version,
|
||||
backup_id,
|
||||
all_files_in_prefix,
|
||||
)
|
||||
try:
|
||||
(
|
||||
result_backup_file,
|
||||
result_metadata_file_version,
|
||||
) = await asyncio.wait_for(
|
||||
self._hass.async_add_executor_job(
|
||||
self._process_metadata_file_for_id_sync,
|
||||
file_name,
|
||||
file_version,
|
||||
backup_id,
|
||||
all_files_in_prefix,
|
||||
),
|
||||
timeout=METADATA_DOWNLOAD_TIMEOUT,
|
||||
)
|
||||
except TimeoutError:
|
||||
_LOGGER.warning(
|
||||
"Timeout downloading metadata file %s while searching for backup %s",
|
||||
file_name,
|
||||
backup_id,
|
||||
)
|
||||
continue
|
||||
if result_backup_file and result_metadata_file_version:
|
||||
return result_backup_file, result_metadata_file_version
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .client import CloudClient
|
||||
@@ -93,7 +94,7 @@ def _convert_content_to_param(
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": content.tool_call_id,
|
||||
"output": json.dumps(content.tool_result),
|
||||
"output": json_dumps(content.tool_result),
|
||||
}
|
||||
)
|
||||
continue
|
||||
@@ -125,7 +126,7 @@ def _convert_content_to_param(
|
||||
{
|
||||
"type": "function_call",
|
||||
"name": tool_call.tool_name,
|
||||
"arguments": json.dumps(tool_call.tool_args),
|
||||
"arguments": json_dumps(tool_call.tool_args),
|
||||
"call_id": tool_call.id,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -196,44 +196,46 @@ class R2BackupAgent(BackupAgent):
|
||||
)
|
||||
upload_id = multipart_upload["UploadId"]
|
||||
try:
|
||||
parts = []
|
||||
parts: list[dict[str, Any]] = []
|
||||
part_number = 1
|
||||
buffer_size = 0 # bytes
|
||||
buffer: list[bytes] = []
|
||||
buffer = bytearray() # bytes buffer to store the data
|
||||
|
||||
stream = await open_stream()
|
||||
async for chunk in stream:
|
||||
buffer_size += len(chunk)
|
||||
buffer.append(chunk)
|
||||
buffer.extend(chunk)
|
||||
|
||||
# upload parts of exactly MULTIPART_MIN_PART_SIZE_BYTES to ensure
|
||||
# all non-trailing parts have the same size (required by S3/R2)
|
||||
while len(buffer) >= MULTIPART_MIN_PART_SIZE_BYTES:
|
||||
part_data = bytes(buffer[:MULTIPART_MIN_PART_SIZE_BYTES])
|
||||
del buffer[:MULTIPART_MIN_PART_SIZE_BYTES]
|
||||
|
||||
# If buffer size meets minimum part size, upload it as a part
|
||||
if buffer_size >= MULTIPART_MIN_PART_SIZE_BYTES:
|
||||
_LOGGER.debug(
|
||||
"Uploading part number %d, size %d", part_number, buffer_size
|
||||
"Uploading part number %d, size %d",
|
||||
part_number,
|
||||
len(part_data),
|
||||
)
|
||||
part = await self._client.upload_part(
|
||||
Bucket=self._bucket,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
PartNumber=part_number,
|
||||
UploadId=upload_id,
|
||||
Body=b"".join(buffer),
|
||||
Body=part_data,
|
||||
)
|
||||
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
|
||||
part_number += 1
|
||||
buffer_size = 0
|
||||
buffer = []
|
||||
|
||||
# Upload the final buffer as the last part (no minimum size requirement)
|
||||
if buffer:
|
||||
_LOGGER.debug(
|
||||
"Uploading final part number %d, size %d", part_number, buffer_size
|
||||
"Uploading final part number %d, size %d", part_number, len(buffer)
|
||||
)
|
||||
part = await self._client.upload_part(
|
||||
Bucket=self._bucket,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
PartNumber=part_number,
|
||||
UploadId=upload_id,
|
||||
Body=b"".join(buffer),
|
||||
Body=bytes(buffer),
|
||||
)
|
||||
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
|
||||
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
"secret_access_key": "Secret access key"
|
||||
},
|
||||
"data_description": {
|
||||
"access_key_id": "Access key ID to connect to Cloudflare R2 (this is your Account ID)",
|
||||
"access_key_id": "Access key ID to connect to Cloudflare R2",
|
||||
"bucket": "Bucket must already exist and be writable by the provided credentials.",
|
||||
"endpoint_url": "Cloudflare R2 S3-compatible endpoint.",
|
||||
"prefix": "Optional folder path inside the bucket. Example: backups/homeassistant",
|
||||
"secret_access_key": "Secret access key to connect to Cloudflare R2. See [Docs]({auth_docs_url})"
|
||||
"secret_access_key": "Secret access key to connect to Cloudflare R2. See [Cloudflare documentation]({auth_docs_url})"
|
||||
},
|
||||
"title": "Add Cloudflare R2 bucket"
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ class ComelitAlarmEntity(
|
||||
"""Update state after action."""
|
||||
self._area.human_status = area_state
|
||||
self._area.armed = armed
|
||||
await self.async_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||
"""Send disarm command."""
|
||||
|
||||
@@ -58,11 +58,12 @@ C4_TO_HA_HVAC_MODE = {
|
||||
|
||||
HA_TO_C4_HVAC_MODE = {v: k for k, v in C4_TO_HA_HVAC_MODE.items()}
|
||||
|
||||
# Map the five known Control4 HVAC states to Home Assistant HVAC actions
|
||||
# Map Control4 HVAC states to Home Assistant HVAC actions
|
||||
C4_TO_HA_HVAC_ACTION = {
|
||||
"off": HVACAction.OFF,
|
||||
"heat": HVACAction.HEATING,
|
||||
"cool": HVACAction.COOLING,
|
||||
"idle": HVACAction.IDLE,
|
||||
"dry": HVACAction.DRYING,
|
||||
"fan": HVACAction.FAN,
|
||||
}
|
||||
@@ -236,8 +237,14 @@ class Control4Climate(Control4Entity, ClimateEntity):
|
||||
c4_state = data.get(CONTROL4_HVAC_STATE)
|
||||
if c4_state is None:
|
||||
return None
|
||||
# Convert state to lowercase for mapping
|
||||
action = C4_TO_HA_HVAC_ACTION.get(str(c4_state).lower())
|
||||
# Substring match for multi-stage systems that report
|
||||
# e.g. "Stage 1 Heat", "Stage 2 Cool"
|
||||
if action is None:
|
||||
if "heat" in str(c4_state).lower():
|
||||
action = HVACAction.HEATING
|
||||
elif "cool" in str(c4_state).lower():
|
||||
action = HVACAction.COOLING
|
||||
if action is None:
|
||||
_LOGGER.debug("Unknown HVAC state received from Control4: %s", c4_state)
|
||||
return action
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pydaikin"],
|
||||
"requirements": ["pydaikin==2.17.1"],
|
||||
"requirements": ["pydaikin==2.17.2"],
|
||||
"zeroconf": ["_dkapi._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["denonavr"],
|
||||
"requirements": ["denonavr==1.3.1"],
|
||||
"requirements": ["denonavr==1.3.2"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
||||
@@ -17,6 +17,7 @@ from denonavr.const import (
|
||||
STATE_ON,
|
||||
STATE_PAUSED,
|
||||
STATE_PLAYING,
|
||||
STATE_STOPPED,
|
||||
)
|
||||
from denonavr.exceptions import (
|
||||
AvrCommandError,
|
||||
@@ -103,6 +104,7 @@ DENON_STATE_MAPPING = {
|
||||
STATE_OFF: MediaPlayerState.OFF,
|
||||
STATE_PLAYING: MediaPlayerState.PLAYING,
|
||||
STATE_PAUSED: MediaPlayerState.PAUSED,
|
||||
STATE_STOPPED: MediaPlayerState.IDLE,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ class EheimDigitalUpdateCoordinator(
|
||||
main_device_added_event=self.main_device_added_event,
|
||||
)
|
||||
self.known_devices: set[str] = set()
|
||||
self.incomplete_devices: set[str] = set()
|
||||
self.platform_callbacks: set[AsyncSetupDeviceEntitiesCallback] = set()
|
||||
|
||||
def add_platform_callback(
|
||||
@@ -70,11 +71,26 @@ class EheimDigitalUpdateCoordinator(
|
||||
This function is called from the library whenever a new device is added.
|
||||
"""
|
||||
|
||||
if device_address not in self.known_devices:
|
||||
if self.hub.devices[device_address].is_missing_data:
|
||||
self.incomplete_devices.add(device_address)
|
||||
return
|
||||
|
||||
if (
|
||||
device_address not in self.known_devices
|
||||
or device_address in self.incomplete_devices
|
||||
):
|
||||
for platform_callback in self.platform_callbacks:
|
||||
platform_callback({device_address: self.hub.devices[device_address]})
|
||||
if device_address in self.incomplete_devices:
|
||||
self.incomplete_devices.remove(device_address)
|
||||
|
||||
async def _async_receive_callback(self) -> None:
|
||||
if any(self.incomplete_devices):
|
||||
for device_address in self.incomplete_devices.copy():
|
||||
if not self.hub.devices[device_address].is_missing_data:
|
||||
await self._async_device_found(
|
||||
device_address, EheimDeviceType.VERSION_UNDEFINED
|
||||
)
|
||||
self.async_set_updated_data(self.hub.devices)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eheimdigital"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["eheimdigital==1.5.0"],
|
||||
"requirements": ["eheimdigital==1.6.0"],
|
||||
"zeroconf": [
|
||||
{ "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." }
|
||||
]
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyenphase==2.4.3"],
|
||||
"requirements": ["pyenphase==2.4.5"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["essent-dynamic-pricing==0.2.7"],
|
||||
"requirements": ["essent-dynamic-pricing==0.3.1"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"codeowners": ["@zxdavb"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/evohome",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["evohome", "evohomeasync", "evohomeasync2"],
|
||||
"loggers": ["evohomeasync", "evohomeasync2"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["evohome-async==1.0.6"]
|
||||
"requirements": ["evohome-async==1.1.3"]
|
||||
}
|
||||
|
||||
@@ -278,6 +278,12 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
"call_deflections"
|
||||
] = await self.async_update_call_deflections()
|
||||
except FRITZ_EXCEPTIONS as ex:
|
||||
_LOGGER.debug(
|
||||
"Reload %s due to error '%s' to ensure proper re-login",
|
||||
self.config_entry.title,
|
||||
ex,
|
||||
)
|
||||
self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from requests.exceptions import ConnectionError as RequestConnectionError, HTTPError
|
||||
|
||||
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, UnitOfTemperature
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
@@ -57,7 +59,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzboxConfigEntry) ->
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: FritzboxConfigEntry) -> bool:
|
||||
"""Unloading the AVM FRITZ!SmartHome platforms."""
|
||||
await hass.async_add_executor_job(entry.runtime_data.fritz.logout)
|
||||
try:
|
||||
await hass.async_add_executor_job(entry.runtime_data.fritz.logout)
|
||||
except (RequestConnectionError, HTTPError) as ex:
|
||||
LOGGER.debug("logout failed with '%s', anyway continue with unload", ex)
|
||||
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -121,26 +121,11 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
|
||||
def _update_fritz_devices(self) -> FritzboxCoordinatorData:
|
||||
"""Update all fritzbox device data."""
|
||||
try:
|
||||
self.fritz.update_devices(ignore_removed=False)
|
||||
if self.has_templates:
|
||||
self.fritz.update_templates(ignore_removed=False)
|
||||
if self.has_triggers:
|
||||
self.fritz.update_triggers(ignore_removed=False)
|
||||
|
||||
except RequestConnectionError as ex:
|
||||
raise UpdateFailed from ex
|
||||
except HTTPError:
|
||||
# If the device rebooted, login again
|
||||
try:
|
||||
self.fritz.login()
|
||||
except LoginError as ex:
|
||||
raise ConfigEntryAuthFailed from ex
|
||||
self.fritz.update_devices(ignore_removed=False)
|
||||
if self.has_templates:
|
||||
self.fritz.update_templates(ignore_removed=False)
|
||||
if self.has_triggers:
|
||||
self.fritz.update_triggers(ignore_removed=False)
|
||||
self.fritz.update_devices(ignore_removed=False)
|
||||
if self.has_templates:
|
||||
self.fritz.update_templates(ignore_removed=False)
|
||||
if self.has_triggers:
|
||||
self.fritz.update_triggers(ignore_removed=False)
|
||||
|
||||
devices = self.fritz.get_devices()
|
||||
device_data = {}
|
||||
@@ -193,7 +178,18 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
|
||||
async def _async_update_data(self) -> FritzboxCoordinatorData:
|
||||
"""Fetch all device data."""
|
||||
new_data = await self.hass.async_add_executor_job(self._update_fritz_devices)
|
||||
try:
|
||||
new_data = await self.hass.async_add_executor_job(
|
||||
self._update_fritz_devices
|
||||
)
|
||||
except (RequestConnectionError, HTTPError) as ex:
|
||||
LOGGER.debug(
|
||||
"Reload %s due to error '%s' to ensure proper re-login",
|
||||
self.config_entry.title,
|
||||
ex,
|
||||
)
|
||||
self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id)
|
||||
raise UpdateFailed from ex
|
||||
|
||||
for device in new_data.devices.values():
|
||||
# create device registry entry for new main devices
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==12.1.3"]
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.0"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["google_air_quality_api"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["google_air_quality_api==3.0.0"]
|
||||
"requirements": ["google_air_quality_api==3.0.1"]
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"pitch": "Default pitch of the voice",
|
||||
"profiles": "Default audio profiles",
|
||||
"speed": "Default rate/speed of the voice",
|
||||
"stt_model": "Speech-to-Text model",
|
||||
"stt_model": "Speech-to-text model",
|
||||
"text_type": "Default text type",
|
||||
"voice": "Default voice name (overrides language and gender)"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import base64
|
||||
import codecs
|
||||
from collections.abc import AsyncGenerator, AsyncIterator, Callable
|
||||
from dataclasses import dataclass, replace
|
||||
import datetime
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
@@ -181,13 +182,25 @@ def _escape_decode(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
def _validate_tool_results(value: Any) -> Any:
|
||||
"""Recursively convert non-json-serializable types."""
|
||||
if isinstance(value, (datetime.time, datetime.date)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, list):
|
||||
return [_validate_tool_results(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {k: _validate_tool_results(v) for k, v in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def _create_google_tool_response_parts(
|
||||
parts: list[conversation.ToolResultContent],
|
||||
) -> list[Part]:
|
||||
"""Create Google tool response parts."""
|
||||
return [
|
||||
Part.from_function_response(
|
||||
name=tool_result.tool_name, response=tool_result.tool_result
|
||||
name=tool_result.tool_name,
|
||||
response=_validate_tool_results(tool_result.tool_result),
|
||||
)
|
||||
for tool_result in parts
|
||||
]
|
||||
|
||||
@@ -38,7 +38,11 @@ SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [
|
||||
translation_key="highest_price_today",
|
||||
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
|
||||
suggested_display_precision=4,
|
||||
value_fn=lambda api, data: api.get_highest_price_today(data),
|
||||
value_fn=lambda api, data: (
|
||||
price / 100
|
||||
if (price := api.get_highest_price_today(data)) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
GreenPlanetEnergySensorEntityDescription(
|
||||
key="gpe_lowest_price_day",
|
||||
@@ -46,7 +50,11 @@ SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [
|
||||
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
|
||||
suggested_display_precision=4,
|
||||
translation_placeholders={"time_range": "(06:00-18:00)"},
|
||||
value_fn=lambda api, data: api.get_lowest_price_day(data),
|
||||
value_fn=lambda api, data: (
|
||||
price / 100
|
||||
if (price := api.get_lowest_price_day(data)) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
GreenPlanetEnergySensorEntityDescription(
|
||||
key="gpe_lowest_price_night",
|
||||
@@ -54,14 +62,22 @@ SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [
|
||||
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
|
||||
suggested_display_precision=4,
|
||||
translation_placeholders={"time_range": "(18:00-06:00)"},
|
||||
value_fn=lambda api, data: api.get_lowest_price_night(data),
|
||||
value_fn=lambda api, data: (
|
||||
price / 100
|
||||
if (price := api.get_lowest_price_night(data)) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
GreenPlanetEnergySensorEntityDescription(
|
||||
key="gpe_current_price",
|
||||
translation_key="current_price",
|
||||
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
|
||||
suggested_display_precision=4,
|
||||
value_fn=lambda api, data: api.get_current_price(data, dt_util.now().hour),
|
||||
value_fn=lambda api, data: (
|
||||
price / 100
|
||||
if (price := api.get_current_price(data, dt_util.now().hour)) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -152,6 +152,8 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Install an update."""
|
||||
self._attr_in_progress = True
|
||||
self.async_write_ha_state()
|
||||
await update_addon(
|
||||
self.hass, self._addon_slug, backup, self.title, self.installed_version
|
||||
)
|
||||
@@ -308,6 +310,8 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity):
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
) -> None:
|
||||
"""Install an update."""
|
||||
self._attr_in_progress = True
|
||||
self.async_write_ha_state()
|
||||
await update_core(self.hass, version, backup)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -31,6 +31,7 @@ HOMEE_UNIT_TO_HA_UNIT = {
|
||||
"n/a": None,
|
||||
"text": None,
|
||||
"%": PERCENTAGE,
|
||||
"Lux": LIGHT_LUX,
|
||||
"lx": LIGHT_LUX,
|
||||
"klx": LIGHT_LUX,
|
||||
"1/min": REVOLUTIONS_PER_MINUTE,
|
||||
|
||||
@@ -161,6 +161,11 @@ class HomematicipHAP:
|
||||
_LOGGER.error("HMIP access point has lost connection with the cloud")
|
||||
self._ws_connection_closed.set()
|
||||
self.set_all_to_unavailable()
|
||||
elif self._ws_connection_closed.is_set():
|
||||
_LOGGER.info("HMIP access point has reconnected to the cloud")
|
||||
self._get_state_task = self.hass.async_create_task(self._try_get_state())
|
||||
self._get_state_task.add_done_callback(self.get_state_finished)
|
||||
self._ws_connection_closed.clear()
|
||||
|
||||
@callback
|
||||
def async_create_entity(self, *args, **kwargs) -> None:
|
||||
|
||||
@@ -556,16 +556,8 @@ class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity):
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the state."""
|
||||
if self.functional_channel is None:
|
||||
return None
|
||||
|
||||
value = self.functional_channel.vaporAmount
|
||||
|
||||
# Handle case where value might be None
|
||||
if (
|
||||
self.functional_channel.vaporAmount is None
|
||||
or self.functional_channel.vaporAmount == ""
|
||||
):
|
||||
value = self._device.vaporAmount
|
||||
if value is None or value == "":
|
||||
return None
|
||||
|
||||
return round(value, 3)
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioautomower"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioautomower==2.7.1"]
|
||||
"requirements": ["aioautomower==2.7.3"]
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioimmich"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioimmich==0.11.1"]
|
||||
"requirements": ["aioimmich==0.12.0"]
|
||||
}
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["intellifire4py"],
|
||||
"requirements": ["intellifire4py==4.2.1"]
|
||||
"requirements": ["intellifire4py==4.3.1"]
|
||||
}
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pypck"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pypck==0.9.9", "lcn-frontend==0.2.7"]
|
||||
"requirements": ["pypck==0.9.11", "lcn-frontend==0.2.7"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["librehardwaremonitor-api==1.8.4"]
|
||||
"requirements": ["librehardwaremonitor-api==1.9.1"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["ical"],
|
||||
"requirements": ["ical==12.1.3"]
|
||||
"requirements": ["ical==13.2.0"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_todo",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["ical==12.1.3"]
|
||||
"requirements": ["ical==13.2.0"]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import datetime
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from mcp import McpError
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
@@ -63,10 +64,15 @@ async def mcp_client(
|
||||
# Method not Allowed likely means this is not a streamable HTTP server,
|
||||
# but it may be an SSE server. This is part of the MCP Transport
|
||||
# backwards compatibility specification.
|
||||
# We also handle other generic McpErrors since proxies may not respond
|
||||
# consistently with a 405.
|
||||
if (
|
||||
isinstance(main_error, httpx.HTTPStatusError)
|
||||
and main_error.response.status_code == 405
|
||||
):
|
||||
) or isinstance(main_error, McpError):
|
||||
_LOGGER.debug(
|
||||
"Streamable HTTP client failed, attempting SSE client: %s", main_error
|
||||
)
|
||||
try:
|
||||
async with (
|
||||
sse_client(url=url, headers=headers) as streams,
|
||||
|
||||
@@ -110,7 +110,7 @@ async def create_server(
|
||||
return [
|
||||
types.TextContent(
|
||||
type="text",
|
||||
text=json.dumps(tool_response),
|
||||
text=json.dumps(tool_response, ensure_ascii=False),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -177,15 +177,15 @@ class ProgramPhaseTumbleDryer(MieleEnum, missing_to_none=True):
|
||||
|
||||
not_running = 0, 512, 535, 536, 537, 65535
|
||||
program_running = 513
|
||||
drying = 514
|
||||
drying = 514, 11018
|
||||
machine_iron = 515
|
||||
hand_iron_2 = 516
|
||||
normal = 517
|
||||
normal_plus = 518
|
||||
cooling_down = 519
|
||||
hand_iron_1 = 520
|
||||
anti_crease = 521
|
||||
finished = 522
|
||||
anti_crease = 521, 11029
|
||||
finished = 522, 11012
|
||||
extra_dry = 523
|
||||
hand_iron = 524
|
||||
moisten = 526
|
||||
@@ -193,12 +193,14 @@ class ProgramPhaseTumbleDryer(MieleEnum, missing_to_none=True):
|
||||
timed_drying = 528
|
||||
warm_air = 529
|
||||
steam_smoothing = 530
|
||||
comfort_cooling = 531
|
||||
comfort_cooling = 531, 11055
|
||||
rinse_out_lint = 532
|
||||
rinses = 533
|
||||
smoothing = 534
|
||||
slightly_dry = 538
|
||||
safety_cooling = 539
|
||||
automatic_start = 11044
|
||||
perfect_dry_active = 11054
|
||||
|
||||
|
||||
class ProgramPhaseWasherDryer(MieleEnum, missing_to_none=True):
|
||||
@@ -265,6 +267,8 @@ class ProgramPhaseOven(MieleEnum, missing_to_none=True):
|
||||
heating_up = 3073
|
||||
process_running = 3074
|
||||
process_finished = 3078
|
||||
searing = 3080
|
||||
roasting = 3081
|
||||
energy_save = 3084
|
||||
pre_heating = 3099
|
||||
|
||||
@@ -357,6 +361,8 @@ class ProgramPhaseSteamOvenCombi(MieleEnum, missing_to_none=True):
|
||||
heating_up = 3073
|
||||
process_running = 3074, 7938
|
||||
process_finished = 3078, 7942
|
||||
searing = 3080
|
||||
roasting = 3081
|
||||
energy_save = 3084
|
||||
pre_heating = 3099
|
||||
|
||||
@@ -483,7 +489,7 @@ class DishWasherProgramId(MieleEnum, missing_to_none=True):
|
||||
no_program = 0, -1
|
||||
intensive = 1, 26, 205
|
||||
maintenance = 2, 27, 214
|
||||
eco = 3, 28, 200
|
||||
eco = 3, 22, 28, 200
|
||||
automatic = 6, 7, 31, 32, 202
|
||||
solar_save = 9, 34
|
||||
gentle = 10, 35, 210
|
||||
@@ -505,30 +511,58 @@ class TumbleDryerProgramId(MieleEnum, missing_to_none=True):
|
||||
|
||||
no_program = 0, -1
|
||||
automatic_plus = 1
|
||||
cottons = 2, 20, 90
|
||||
minimum_iron = 3, 30
|
||||
woollens_handcare = 4, 40
|
||||
delicates = 5, 50
|
||||
warm_air = 6, 60
|
||||
cool_air = 7, 70
|
||||
express = 8, 80
|
||||
cottons = 2, 20, 90, 10001
|
||||
minimum_iron = 3, 30, 10016
|
||||
woollens_handcare = 4, 40, 10081
|
||||
woollens = 10040
|
||||
delicates = 5, 50, 10022
|
||||
warm_air = 6, 60, 10025
|
||||
cool_air = 7, 70, 10027
|
||||
express = 8, 80, 10028
|
||||
cottons_eco = 9, 99003
|
||||
proofing = 12, 120
|
||||
denim = 13, 130
|
||||
proofing = 12, 120, 10057
|
||||
denim = 13, 130, 10039
|
||||
shirts = 14, 99004
|
||||
sportswear = 15, 150
|
||||
outerwear = 16, 160
|
||||
silks_handcare = 17, 170
|
||||
sportswear = 15, 150, 10052
|
||||
outerwear = 16, 160, 10049
|
||||
silks_handcare = 17, 170, 10082
|
||||
standard_pillows = 19, 190
|
||||
basket_program = 22, 220
|
||||
basket_program = 22, 220, 10072
|
||||
cottons_hygiene = 11, 23
|
||||
smoothing = 24, 240
|
||||
bed_linen = 31, 99002
|
||||
eco = 66
|
||||
smoothing = 24, 240, 10073
|
||||
bed_linen = 31, 99002, 10047
|
||||
eco = 66, 10079
|
||||
gentle_smoothing = 10, 100
|
||||
gentle_denim = 131
|
||||
steam_smoothing = 99001
|
||||
large_pillows = 99005
|
||||
downs_duvets = 10050
|
||||
curtains = 10055
|
||||
quick_power_dry = 10032
|
||||
automatic = 10044
|
||||
quick_hygiene = 10076
|
||||
hygiene = 10080
|
||||
pillows_sanitize = 10092
|
||||
custom_program_1 = 13901
|
||||
custom_program_2 = 13902
|
||||
custom_program_3 = 13903
|
||||
custom_program_4 = 13904
|
||||
custom_program_5 = 13905
|
||||
custom_program_6 = 13906
|
||||
custom_program_7 = 13907
|
||||
custom_program_8 = 13908
|
||||
custom_program_9 = 13909
|
||||
custom_program_10 = 13910
|
||||
custom_program_11 = 13911
|
||||
custom_program_12 = 13912
|
||||
custom_program_13 = 13913
|
||||
custom_program_14 = 13914
|
||||
custom_program_15 = 13915
|
||||
custom_program_16 = 13916
|
||||
custom_program_17 = 13917
|
||||
custom_program_18 = 13918
|
||||
custom_program_19 = 13919
|
||||
custom_program_20 = 13920
|
||||
|
||||
|
||||
class OvenProgramId(MieleEnum, missing_to_none=True):
|
||||
|
||||
@@ -461,6 +461,7 @@
|
||||
"dissolve_gelatine": "Dissolve gelatine",
|
||||
"down_duvets": "Down duvets",
|
||||
"down_filled_items": "Down-filled items",
|
||||
"downs_duvets": "Downs/Duvets",
|
||||
"drain_spin": "Drain/spin",
|
||||
"drop_cookies_1_tray": "Drop cookies (1 tray)",
|
||||
"drop_cookies_2_trays": "Drop cookies (2 trays)",
|
||||
@@ -665,6 +666,7 @@
|
||||
"pike_fillet": "Pike (fillet)",
|
||||
"pike_piece": "Pike (piece)",
|
||||
"pillows": "Pillows",
|
||||
"pillows_sanitize": "Pillows sanitize",
|
||||
"pinto_beans": "Pinto beans",
|
||||
"pizza_oil_cheese_dough_baking_tray": "Pizza, oil cheese dough (baking tray)",
|
||||
"pizza_oil_cheese_dough_round_baking_tine": "Pizza, oil cheese dough (round baking tine)",
|
||||
@@ -732,8 +734,8 @@
|
||||
"potatoes_waxy_whole_small": "Potatoes (waxy, whole, small)",
|
||||
"poularde_breast": "Poularde breast",
|
||||
"poularde_whole": "Poularde (whole)",
|
||||
"power_fresh": "PowerFresh",
|
||||
"power_wash": "PowerWash",
|
||||
"powerfresh": "PowerFresh",
|
||||
"prawns": "Prawns",
|
||||
"pre_ironing": "Pre-ironing",
|
||||
"proofing": "Proofing",
|
||||
@@ -746,7 +748,9 @@
|
||||
"pumpkin_soup": "Pumpkin soup",
|
||||
"pyrolytic": "Pyrolytic",
|
||||
"quiche_lorraine": "Quiche Lorraine",
|
||||
"quick_hygiene": "QuickHygiene",
|
||||
"quick_mw": "Quick MW",
|
||||
"quick_power_dry": "QuickPowerDry",
|
||||
"quick_power_wash": "QuickPowerWash",
|
||||
"quinces_diced": "Quinces (diced)",
|
||||
"quinoa": "Quinoa",
|
||||
@@ -1004,6 +1008,7 @@
|
||||
"normal": "Normal",
|
||||
"normal_plus": "Normal plus",
|
||||
"not_running": "Not running",
|
||||
"perfect_dry_active": "PerfectDry active",
|
||||
"pre_brewing": "Pre-brewing",
|
||||
"pre_dishwash": "Pre-cleaning",
|
||||
"pre_heating": "Pre-heating",
|
||||
@@ -1018,7 +1023,9 @@
|
||||
"rinse_hold": "Rinse hold",
|
||||
"rinse_out_lint": "Rinse out lint",
|
||||
"rinses": "Rinses",
|
||||
"roasting": "Roasting",
|
||||
"safety_cooling": "Safety cooling",
|
||||
"searing": "Searing",
|
||||
"slightly_dry": "Slightly dry",
|
||||
"slow_roasting": "Slow roasting",
|
||||
"smoothing": "Smoothing",
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pynintendoauth", "pynintendoparental"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.2"]
|
||||
"requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.2.1"]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, cast
|
||||
|
||||
from nrgkick_api import ChargingStatus
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -632,11 +634,18 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
|
||||
key="vehicle_connected_since",
|
||||
translation_key="vehicle_connected_since",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda data: _seconds_to_stable_timestamp(
|
||||
cast(
|
||||
StateType,
|
||||
_get_nested_dict_value(data.values, "general", "vehicle_connect_time"),
|
||||
value_fn=lambda data: (
|
||||
_seconds_to_stable_timestamp(
|
||||
cast(
|
||||
StateType,
|
||||
_get_nested_dict_value(
|
||||
data.values, "general", "vehicle_connect_time"
|
||||
),
|
||||
)
|
||||
)
|
||||
if _get_nested_dict_value(data.values, "general", "status")
|
||||
!= ChargingStatus.STANDBY
|
||||
else None
|
||||
),
|
||||
),
|
||||
NRGkickSensorEntityDescription(
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
|
||||
from . import OllamaConfigEntry
|
||||
from .const import (
|
||||
@@ -93,7 +94,7 @@ def _convert_content(
|
||||
if isinstance(chat_content, conversation.ToolResultContent):
|
||||
return ollama.Message(
|
||||
role=MessageRole.TOOL.value,
|
||||
content=json.dumps(chat_content.tool_result),
|
||||
content=json_dumps(chat_content.tool_result),
|
||||
)
|
||||
if isinstance(chat_content, conversation.AssistantContent):
|
||||
return ollama.Message(
|
||||
|
||||
@@ -148,6 +148,12 @@ class OneDriveBackupAgent(BackupAgent):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup."""
|
||||
expires_at = self._entry.data["token"]["expires_at"]
|
||||
_LOGGER.debug(
|
||||
"Starting backup upload, token expiry: %s (in %s seconds)",
|
||||
expires_at,
|
||||
expires_at - time(),
|
||||
)
|
||||
backup_filename, metadata_filename = suggested_filenames(backup)
|
||||
file = FileInfo(
|
||||
backup_filename,
|
||||
|
||||
@@ -6,6 +6,7 @@ from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from time import time
|
||||
|
||||
from onedrive_personal_sdk import OneDriveClient
|
||||
from onedrive_personal_sdk.const import DriveState
|
||||
@@ -58,6 +59,12 @@ class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]):
|
||||
|
||||
async def _async_update_data(self) -> Drive:
|
||||
"""Fetch data from API endpoint."""
|
||||
expires_at = self.config_entry.data["token"]["expires_at"]
|
||||
_LOGGER.debug(
|
||||
"Token expiry: %s (in %s seconds)",
|
||||
expires_at,
|
||||
expires_at - time(),
|
||||
)
|
||||
|
||||
try:
|
||||
drive = await self._client.get_drive()
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["onedrive_personal_sdk"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["onedrive-personal-sdk==0.1.1"]
|
||||
"requirements": ["onedrive-personal-sdk==0.1.2"]
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
|
||||
from . import OpenRouterConfigEntry
|
||||
from .const import DOMAIN, LOGGER
|
||||
@@ -109,7 +110,7 @@ def _convert_content_to_chat_message(
|
||||
return ChatCompletionToolMessageParam(
|
||||
role="tool",
|
||||
tool_call_id=content.tool_call_id,
|
||||
content=json.dumps(content.tool_result),
|
||||
content=json_dumps(content.tool_result),
|
||||
)
|
||||
|
||||
role: Literal["user", "assistant", "system"] = content.role
|
||||
@@ -130,7 +131,7 @@ def _convert_content_to_chat_message(
|
||||
type="function",
|
||||
id=tool_call.id,
|
||||
function=Function(
|
||||
arguments=json.dumps(tool_call.tool_args),
|
||||
arguments=json_dumps(tool_call.tool_args),
|
||||
name=tool_call.tool_name,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -64,6 +64,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, issue_registry as ir, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .const import (
|
||||
@@ -183,7 +184,7 @@ def _convert_content_to_param(
|
||||
FunctionCallOutput(
|
||||
type="function_call_output",
|
||||
call_id=content.tool_call_id,
|
||||
output=json.dumps(content.tool_result),
|
||||
output=json_dumps(content.tool_result),
|
||||
)
|
||||
)
|
||||
continue
|
||||
@@ -217,7 +218,7 @@ def _convert_content_to_param(
|
||||
ResponseFunctionToolCallParam(
|
||||
type="function_call",
|
||||
name=tool_call.tool_name,
|
||||
arguments=json.dumps(tool_call.tool_args),
|
||||
arguments=json_dumps(tool_call.tool_args),
|
||||
call_id=tool_call.id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -76,27 +76,29 @@ def get_upload_speed(coordinator: QBittorrentDataCoordinator) -> int:
|
||||
|
||||
|
||||
def get_download_speed_limit(coordinator: QBittorrentDataCoordinator) -> int:
|
||||
"""Get current download speed."""
|
||||
"""Get current download speed limit."""
|
||||
server_state = cast(Mapping, coordinator.data.get("server_state"))
|
||||
return cast(int, server_state.get("dl_rate_limit"))
|
||||
|
||||
|
||||
def get_upload_speed_limit(coordinator: QBittorrentDataCoordinator) -> int:
|
||||
"""Get current upload speed."""
|
||||
"""Get current upload speed limit."""
|
||||
server_state = cast(Mapping[str, Any], coordinator.data.get("server_state"))
|
||||
return cast(int, server_state.get("up_rate_limit"))
|
||||
|
||||
|
||||
def get_alltime_download(coordinator: QBittorrentDataCoordinator) -> int:
|
||||
"""Get current download speed."""
|
||||
def get_alltime_download(coordinator: QBittorrentDataCoordinator) -> int | None:
|
||||
"""Get all-time download volume."""
|
||||
server_state = cast(Mapping, coordinator.data.get("server_state"))
|
||||
return cast(int, server_state.get("alltime_dl"))
|
||||
value = cast(int, server_state.get("alltime_dl"))
|
||||
return value or None
|
||||
|
||||
|
||||
def get_alltime_upload(coordinator: QBittorrentDataCoordinator) -> int:
|
||||
"""Get current download speed."""
|
||||
def get_alltime_upload(coordinator: QBittorrentDataCoordinator) -> int | None:
|
||||
"""Get all-time upload volume."""
|
||||
server_state = cast(Mapping, coordinator.data.get("server_state"))
|
||||
return cast(int, server_state.get("alltime_ul"))
|
||||
value = cast(int, server_state.get("alltime_ul"))
|
||||
return value or None
|
||||
|
||||
|
||||
def get_global_ratio(coordinator: QBittorrentDataCoordinator) -> float:
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyrainbird"],
|
||||
"requirements": ["pyrainbird==6.0.1"]
|
||||
"requirements": ["pyrainbird==6.0.5"]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Calendar platform for a Remote Calendar."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
from ical.event import Event
|
||||
from ical.timeline import Timeline, materialize_timeline
|
||||
|
||||
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -20,6 +21,14 @@ _LOGGER = logging.getLogger(__name__)
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
# Every coordinator update refresh, we materialize a timeline of upcoming
|
||||
# events for determining state. This is done in the background to avoid blocking
|
||||
# the event loop. When a state update happens we can scan for active events on
|
||||
# the materialized timeline. These parameters control the maximum lookahead
|
||||
# window and number of events we materialize from the calendar.
|
||||
MAX_LOOKAHEAD_EVENTS = 20
|
||||
MAX_LOOKAHEAD_TIME = timedelta(days=365)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -48,12 +57,18 @@ class RemoteCalendarEntity(
|
||||
super().__init__(coordinator)
|
||||
self._attr_name = entry.data[CONF_CALENDAR_NAME]
|
||||
self._attr_unique_id = entry.entry_id
|
||||
self._event: CalendarEvent | None = None
|
||||
self._timeline: Timeline | None = None
|
||||
|
||||
@property
|
||||
def event(self) -> CalendarEvent | None:
|
||||
"""Return the next upcoming event."""
|
||||
return self._event
|
||||
if self._timeline is None:
|
||||
return None
|
||||
now = dt_util.now()
|
||||
events = self._timeline.active_after(now)
|
||||
if event := next(events, None):
|
||||
return _get_calendar_event(event)
|
||||
return None
|
||||
|
||||
async def async_get_events(
|
||||
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
|
||||
@@ -79,14 +94,18 @@ class RemoteCalendarEntity(
|
||||
"""
|
||||
await super().async_update()
|
||||
|
||||
def next_event() -> CalendarEvent | None:
|
||||
def _get_timeline() -> Timeline | None:
|
||||
"""Return a materialized timeline with upcoming events."""
|
||||
now = dt_util.now()
|
||||
events = self.coordinator.data.timeline_tz(now.tzinfo).active_after(now)
|
||||
if event := next(events, None):
|
||||
return _get_calendar_event(event)
|
||||
return None
|
||||
timeline = self.coordinator.data.timeline_tz(now.tzinfo)
|
||||
return materialize_timeline(
|
||||
timeline,
|
||||
start=now,
|
||||
stop=now + MAX_LOOKAHEAD_TIME,
|
||||
max_number_of_events=MAX_LOOKAHEAD_EVENTS,
|
||||
)
|
||||
|
||||
self._event = await self.hass.async_add_executor_job(next_event)
|
||||
self._timeline = await self.hass.async_add_executor_job(_get_timeline)
|
||||
|
||||
|
||||
def _get_calendar_event(event: Event) -> CalendarEvent:
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["ical"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["ical==12.1.3"]
|
||||
"requirements": ["ical==13.2.0"]
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["reolink-aio==0.18.2"]
|
||||
"requirements": ["reolink-aio==0.19.0"]
|
||||
}
|
||||
|
||||
@@ -87,11 +87,12 @@ NUMBER_ENTITIES = (
|
||||
ReolinkNumberEntityDescription(
|
||||
key="zoom",
|
||||
cmd_key="GetZoomFocus",
|
||||
cmd_id=294,
|
||||
translation_key="zoom",
|
||||
mode=NumberMode.SLIDER,
|
||||
native_step=1,
|
||||
get_min_value=lambda api, ch: api.zoom_range(ch)["zoom"]["pos"]["min"],
|
||||
get_max_value=lambda api, ch: api.zoom_range(ch)["zoom"]["pos"]["max"],
|
||||
get_min_value=lambda api, ch: api.zoom_range(ch)["zoom"]["min"],
|
||||
get_max_value=lambda api, ch: api.zoom_range(ch)["zoom"]["max"],
|
||||
supported=lambda api, ch: api.supported(ch, "zoom"),
|
||||
value=lambda api, ch: api.get_zoom(ch),
|
||||
method=lambda api, ch, value: api.set_zoom(ch, int(value)),
|
||||
@@ -99,11 +100,12 @@ NUMBER_ENTITIES = (
|
||||
ReolinkNumberEntityDescription(
|
||||
key="focus",
|
||||
cmd_key="GetZoomFocus",
|
||||
cmd_id=294,
|
||||
translation_key="focus",
|
||||
mode=NumberMode.SLIDER,
|
||||
native_step=1,
|
||||
get_min_value=lambda api, ch: api.zoom_range(ch)["focus"]["pos"]["min"],
|
||||
get_max_value=lambda api, ch: api.zoom_range(ch)["focus"]["pos"]["max"],
|
||||
get_min_value=lambda api, ch: api.zoom_range(ch)["focus"]["min"],
|
||||
get_max_value=lambda api, ch: api.zoom_range(ch)["focus"]["max"],
|
||||
supported=lambda api, ch: api.supported(ch, "focus"),
|
||||
value=lambda api, ch: api.get_focus(ch),
|
||||
method=lambda api, ch, value: api.set_focus(ch, int(value)),
|
||||
|
||||
@@ -61,6 +61,7 @@ class ReolinkHostSensorEntityDescription(
|
||||
SENSORS = (
|
||||
ReolinkSensorEntityDescription(
|
||||
key="ptz_pan_position",
|
||||
cmd_id=433,
|
||||
cmd_key="GetPtzCurPos",
|
||||
translation_key="ptz_pan_position",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -70,6 +71,7 @@ SENSORS = (
|
||||
),
|
||||
ReolinkSensorEntityDescription(
|
||||
key="ptz_tilt_position",
|
||||
cmd_id=433,
|
||||
cmd_key="GetPtzCurPos",
|
||||
translation_key="ptz_tilt_position",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
|
||||
@@ -29,17 +29,24 @@ from homeassistant.const import CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from . import RoborockConfigEntry
|
||||
from .const import (
|
||||
CONF_BASE_URL,
|
||||
CONF_ENTRY_CODE,
|
||||
CONF_REGION,
|
||||
CONF_SHOW_BACKGROUND,
|
||||
CONF_USER_DATA,
|
||||
DEFAULT_DRAWABLES,
|
||||
DOMAIN,
|
||||
DRAWABLES,
|
||||
REGION_OPTIONS,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -64,17 +71,35 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input is not None:
|
||||
username = user_input[CONF_USERNAME]
|
||||
region = user_input[CONF_REGION]
|
||||
self._username = username
|
||||
_LOGGER.debug("Requesting code for Roborock account")
|
||||
base_url = None
|
||||
if region != "auto":
|
||||
base_url = f"https://{region}iot.roborock.com"
|
||||
self._client = RoborockApiClient(
|
||||
username, session=async_get_clientsession(self.hass)
|
||||
username,
|
||||
base_url=base_url,
|
||||
session=async_get_clientsession(self.hass),
|
||||
)
|
||||
errors = await self._request_code()
|
||||
if not errors:
|
||||
return await self.async_step_code()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({vol.Required(CONF_USERNAME): str}),
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_REGION, default="auto"): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=REGION_OPTIONS,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key="region",
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -114,6 +139,8 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
user_data = await self._client.code_login_v4(code)
|
||||
except RoborockInvalidCode:
|
||||
errors["base"] = "invalid_code"
|
||||
except RoborockAccountDoesNotExist:
|
||||
errors["base"] = "invalid_email_or_region"
|
||||
except RoborockException:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown_roborock"
|
||||
|
||||
@@ -11,7 +11,8 @@ CONF_ENTRY_CODE = "code"
|
||||
CONF_BASE_URL = "base_url"
|
||||
CONF_USER_DATA = "user_data"
|
||||
CONF_SHOW_BACKGROUND = "show_background"
|
||||
|
||||
CONF_REGION = "region"
|
||||
REGION_OPTIONS = ["auto", "us", "eu", "ru", "cn"]
|
||||
# Option Flow steps
|
||||
DRAWABLES = "drawables"
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"invalid_code": "The code you entered was incorrect, please check it and try again.",
|
||||
"invalid_email": "There is no account associated with the email you entered, please try again.",
|
||||
"invalid_email_format": "There is an issue with the formatting of your email - please try again.",
|
||||
"invalid_email_or_region": "Either there is no account associated with the email you entered, or there is no account in the selected region.",
|
||||
"too_frequent_code_requests": "You have attempted to request too many codes. Try again later.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"unknown_roborock": "There was an unknown Roborock exception - please check your logs.",
|
||||
@@ -30,9 +31,11 @@
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"region": "Roborock server region",
|
||||
"username": "[%key:common::config_flow::data::email%]"
|
||||
},
|
||||
"data_description": {
|
||||
"region": "The server region your Roborock account is registered in when setting up the app. Auto is recommended unless you are having issues.",
|
||||
"username": "The email address used to sign in to the Roborock app."
|
||||
},
|
||||
"description": "Enter your Roborock email address."
|
||||
@@ -545,6 +548,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"region": {
|
||||
"options": {
|
||||
"auto": "Auto",
|
||||
"cn": "CN",
|
||||
"eu": "EU",
|
||||
"ru": "RU",
|
||||
"us": "US"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_maps": {
|
||||
"description": "Retrieves the map and room information of your device.",
|
||||
|
||||
@@ -104,7 +104,6 @@ class RpcLinkedgoThermostatClimate(ShellyRpcAttributeEntity, ClimateEntity):
|
||||
)
|
||||
|
||||
config = coordinator.device.config
|
||||
self._status = coordinator.device.status
|
||||
|
||||
self._attr_min_temp = config[key]["min"]
|
||||
self._attr_max_temp = config[key]["max"]
|
||||
@@ -142,6 +141,11 @@ class RpcLinkedgoThermostatClimate(ShellyRpcAttributeEntity, ClimateEntity):
|
||||
THERMOSTAT_TO_HA_MODE[mode] for mode in modes
|
||||
]
|
||||
|
||||
@property
|
||||
def _status(self) -> dict[str, Any]:
|
||||
"""Return the full device status."""
|
||||
return self.coordinator.device.status
|
||||
|
||||
@property
|
||||
def current_humidity(self) -> float | None:
|
||||
"""Return the current humidity."""
|
||||
|
||||
@@ -31,5 +31,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pysmartthings"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pysmartthings==3.5.1"]
|
||||
"requirements": ["pysmartthings==3.5.3"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/smarttub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["smarttub"],
|
||||
"requirements": ["python-smarttub==0.0.46"]
|
||||
"requirements": ["python-smarttub==0.0.47"]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "[%key:component::smarttub::config::step::user::data_description::email%]",
|
||||
"password": "[%key:component::smarttub::config::step::user::data_description::password%]"
|
||||
},
|
||||
"description": "The SmartTub integration needs to re-authenticate your account",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
@@ -17,6 +25,10 @@
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "The email address associated with your SmartTub account",
|
||||
"password": "The password for your SmartTub account"
|
||||
},
|
||||
"description": "Enter your SmartTub email address and password to log in",
|
||||
"title": "Login"
|
||||
}
|
||||
|
||||
@@ -659,23 +659,41 @@ class TelegramNotificationService:
|
||||
|
||||
media: InputMedia
|
||||
if media_type == InputMediaType.ANIMATION:
|
||||
media = InputMediaAnimation(file_content, caption=kwargs.get(ATTR_CAPTION))
|
||||
media = InputMediaAnimation(
|
||||
file_content,
|
||||
caption=kwargs.get(ATTR_CAPTION),
|
||||
parse_mode=params[ATTR_PARSER],
|
||||
)
|
||||
elif media_type == InputMediaType.AUDIO:
|
||||
media = InputMediaAudio(file_content, caption=kwargs.get(ATTR_CAPTION))
|
||||
media = InputMediaAudio(
|
||||
file_content,
|
||||
caption=kwargs.get(ATTR_CAPTION),
|
||||
parse_mode=params[ATTR_PARSER],
|
||||
)
|
||||
elif media_type == InputMediaType.DOCUMENT:
|
||||
media = InputMediaDocument(file_content, caption=kwargs.get(ATTR_CAPTION))
|
||||
media = InputMediaDocument(
|
||||
file_content,
|
||||
caption=kwargs.get(ATTR_CAPTION),
|
||||
parse_mode=params[ATTR_PARSER],
|
||||
)
|
||||
elif media_type == InputMediaType.PHOTO:
|
||||
media = InputMediaPhoto(file_content, caption=kwargs.get(ATTR_CAPTION))
|
||||
media = InputMediaPhoto(
|
||||
file_content,
|
||||
caption=kwargs.get(ATTR_CAPTION),
|
||||
parse_mode=params[ATTR_PARSER],
|
||||
)
|
||||
else:
|
||||
media = InputMediaVideo(file_content, caption=kwargs.get(ATTR_CAPTION))
|
||||
media = InputMediaVideo(
|
||||
file_content,
|
||||
caption=kwargs.get(ATTR_CAPTION),
|
||||
parse_mode=params[ATTR_PARSER],
|
||||
)
|
||||
|
||||
return await self._send_msg(
|
||||
self.bot.edit_message_media,
|
||||
"Error editing message media",
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
media=media,
|
||||
caption=kwargs.get(ATTR_CAPTION),
|
||||
parse_mode=params[ATTR_PARSER],
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
inline_message_id=inline_message_id,
|
||||
|
||||
@@ -237,9 +237,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
# validate connection to Telegram API
|
||||
errors: dict[str, str] = {}
|
||||
user_input[CONF_API_ENDPOINT] = (
|
||||
user_input[SECTION_ADVANCED_SETTINGS][CONF_API_ENDPOINT],
|
||||
)
|
||||
user_input[CONF_API_ENDPOINT] = user_input[SECTION_ADVANCED_SETTINGS][
|
||||
CONF_API_ENDPOINT
|
||||
]
|
||||
user_input[CONF_PROXY_URL] = user_input[SECTION_ADVANCED_SETTINGS].get(
|
||||
CONF_PROXY_URL
|
||||
)
|
||||
|
||||
@@ -10,11 +10,7 @@ from typing import Any, cast
|
||||
import jwt
|
||||
from tesla_fleet_api import TeslaFleetApi
|
||||
from tesla_fleet_api.const import SERVERS
|
||||
from tesla_fleet_api.exceptions import (
|
||||
InvalidResponse,
|
||||
PreconditionFailed,
|
||||
TeslaFleetError,
|
||||
)
|
||||
from tesla_fleet_api.exceptions import PreconditionFailed, TeslaFleetError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
@@ -41,12 +37,9 @@ class OAuth2FlowHandler(
|
||||
"""Initialize config flow."""
|
||||
super().__init__()
|
||||
self.domain: str | None = None
|
||||
self.registration_status: dict[str, bool] = {}
|
||||
self.tesla_apis: dict[str, TeslaFleetApi] = {}
|
||||
self.failed_regions: list[str] = []
|
||||
self.data: dict[str, Any] = {}
|
||||
self.uid: str | None = None
|
||||
self.api: TeslaFleetApi | None = None
|
||||
self.apis: list[TeslaFleetApi] = []
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
@@ -64,7 +57,6 @@ class OAuth2FlowHandler(
|
||||
|
||||
self.data = data
|
||||
self.uid = token["sub"]
|
||||
server = SERVERS[token["ou_code"].lower()]
|
||||
|
||||
await self.async_set_unique_id(self.uid)
|
||||
if self.source == SOURCE_REAUTH:
|
||||
@@ -74,24 +66,28 @@ class OAuth2FlowHandler(
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# OAuth done, setup a Partner API connection
|
||||
# OAuth done, setup Partner API connections for all regions
|
||||
implementation = cast(TeslaUserImplementation, self.flow_impl)
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
self.api = TeslaFleetApi(
|
||||
access_token="",
|
||||
session=session,
|
||||
server=server,
|
||||
partner_scope=True,
|
||||
charging_scope=False,
|
||||
energy_scope=False,
|
||||
user_scope=False,
|
||||
vehicle_scope=False,
|
||||
)
|
||||
await self.api.get_private_key(self.hass.config.path("tesla_fleet.key"))
|
||||
await self.api.partner_login(
|
||||
implementation.client_id, implementation.client_secret
|
||||
)
|
||||
|
||||
for region, server_url in SERVERS.items():
|
||||
if region == "cn":
|
||||
continue
|
||||
api = TeslaFleetApi(
|
||||
session=session,
|
||||
access_token="",
|
||||
server=server_url,
|
||||
partner_scope=True,
|
||||
charging_scope=False,
|
||||
energy_scope=False,
|
||||
user_scope=False,
|
||||
vehicle_scope=False,
|
||||
)
|
||||
await api.get_private_key(self.hass.config.path("tesla_fleet.key"))
|
||||
await api.partner_login(
|
||||
implementation.client_id, implementation.client_secret
|
||||
)
|
||||
self.apis.append(api)
|
||||
|
||||
return await self.async_step_domain_input()
|
||||
|
||||
@@ -130,44 +126,67 @@ class OAuth2FlowHandler(
|
||||
async def async_step_domain_registration(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle domain registration for both regions."""
|
||||
"""Handle domain registration for all regions."""
|
||||
|
||||
assert self.api
|
||||
assert self.api.private_key
|
||||
assert self.apis
|
||||
assert self.apis[0].private_key
|
||||
assert self.domain
|
||||
|
||||
errors = {}
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders = {
|
||||
"public_key_url": f"https://{self.domain}/.well-known/appspecific/com.tesla.3p.public-key.pem",
|
||||
"pem": self.api.public_pem,
|
||||
"pem": self.apis[0].public_pem,
|
||||
}
|
||||
|
||||
try:
|
||||
register_response = await self.api.partner.register(self.domain)
|
||||
except PreconditionFailed:
|
||||
return await self.async_step_domain_input(
|
||||
errors={CONF_DOMAIN: "precondition_failed"}
|
||||
)
|
||||
except InvalidResponse:
|
||||
successful_response: dict[str, Any] | None = None
|
||||
failed_regions: list[str] = []
|
||||
|
||||
for api in self.apis:
|
||||
try:
|
||||
register_response = await api.partner.register(self.domain)
|
||||
except PreconditionFailed:
|
||||
return await self.async_step_domain_input(
|
||||
errors={CONF_DOMAIN: "precondition_failed"}
|
||||
)
|
||||
except TeslaFleetError as e:
|
||||
LOGGER.warning(
|
||||
"Partner registration failed for %s: %s",
|
||||
api.server,
|
||||
e.message,
|
||||
)
|
||||
failed_regions.append(api.server or "unknown")
|
||||
else:
|
||||
if successful_response is None:
|
||||
successful_response = register_response
|
||||
|
||||
if successful_response is None:
|
||||
errors["base"] = "invalid_response"
|
||||
except TeslaFleetError as e:
|
||||
errors["base"] = "unknown_error"
|
||||
description_placeholders["error"] = e.message
|
||||
else:
|
||||
# Get public key from response
|
||||
registered_public_key = register_response.get("response", {}).get(
|
||||
"public_key"
|
||||
return self.async_show_form(
|
||||
step_id="domain_registration",
|
||||
description_placeholders=description_placeholders,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
if not registered_public_key:
|
||||
errors["base"] = "public_key_not_found"
|
||||
elif (
|
||||
registered_public_key.lower()
|
||||
!= self.api.public_uncompressed_point.lower()
|
||||
):
|
||||
errors["base"] = "public_key_mismatch"
|
||||
else:
|
||||
return await self.async_step_registration_complete()
|
||||
if failed_regions:
|
||||
LOGGER.warning(
|
||||
"Partner registration succeeded on some regions but failed on: %s",
|
||||
", ".join(failed_regions),
|
||||
)
|
||||
|
||||
# Verify public key from the successful response
|
||||
registered_public_key = successful_response.get("response", {}).get(
|
||||
"public_key"
|
||||
)
|
||||
|
||||
if not registered_public_key:
|
||||
errors["base"] = "public_key_not_found"
|
||||
elif (
|
||||
registered_public_key.lower()
|
||||
!= self.apis[0].public_uncompressed_point.lower()
|
||||
):
|
||||
errors["base"] = "public_key_mismatch"
|
||||
else:
|
||||
return await self.async_step_registration_complete()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="domain_registration",
|
||||
|
||||
@@ -436,7 +436,6 @@ ENERGY_INFO_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="vpp_backup_reserve_percent",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
SensorEntityDescription(key="version"),
|
||||
|
||||
@@ -1529,7 +1529,6 @@ ENERGY_INFO_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="vpp_backup_reserve_percent",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
|
||||
@@ -364,7 +364,6 @@ ENERGY_INFO_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = (
|
||||
TessieSensorEntityDescription(
|
||||
key="vpp_backup_reserve_percent",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -93,4 +93,7 @@ COLLABORATORS: Final = "collaborators"
|
||||
|
||||
DOMAIN: Final = "todoist"
|
||||
|
||||
# Maximum number of items per page for Todoist API requests
|
||||
MAX_PAGE_SIZE: Final = 200
|
||||
|
||||
SERVICE_NEW_TASK: Final = "new_task"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""DataUpdateCoordinator for the Todoist component."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
@@ -12,6 +13,8 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import MAX_PAGE_SIZE
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@@ -53,26 +56,30 @@ class TodoistCoordinator(DataUpdateCoordinator[list[Task]]):
|
||||
async def _async_update_data(self) -> list[Task]:
|
||||
"""Fetch tasks from the Todoist API."""
|
||||
try:
|
||||
tasks_async = await self.api.get_tasks()
|
||||
tasks_async = await self.api.get_tasks(limit=MAX_PAGE_SIZE)
|
||||
return await flatten_async_pages(tasks_async)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
return await flatten_async_pages(tasks_async)
|
||||
|
||||
async def async_get_projects(self) -> list[Project]:
|
||||
"""Return todoist projects fetched at most once."""
|
||||
if self._projects is None:
|
||||
projects_async = await self.api.get_projects()
|
||||
projects_async = await self.api.get_projects(limit=MAX_PAGE_SIZE)
|
||||
self._projects = await flatten_async_pages(projects_async)
|
||||
return self._projects
|
||||
|
||||
async def async_get_sections(self, project_id: str) -> list[Section]:
|
||||
"""Return todoist sections for a given project ID."""
|
||||
sections_async = await self.api.get_sections(project_id=project_id)
|
||||
sections_async = await self.api.get_sections(
|
||||
project_id=project_id, limit=MAX_PAGE_SIZE
|
||||
)
|
||||
return await flatten_async_pages(sections_async)
|
||||
|
||||
async def async_get_labels(self) -> list[Label]:
|
||||
"""Return todoist labels fetched at most once."""
|
||||
if self._labels is None:
|
||||
labels_async = await self.api.get_labels()
|
||||
labels_async = await self.api.get_labels(limit=MAX_PAGE_SIZE)
|
||||
self._labels = await flatten_async_pages(labels_async)
|
||||
return self._labels
|
||||
|
||||
@@ -45,9 +45,9 @@ def _task_api_data(item: TodoItem, api_data: Task | None = None) -> dict[str, An
|
||||
}
|
||||
if due := item.due:
|
||||
if isinstance(due, datetime.datetime):
|
||||
item_data["due_datetime"] = due.isoformat()
|
||||
item_data["due_datetime"] = due
|
||||
else:
|
||||
item_data["due_date"] = due.isoformat()
|
||||
item_data["due_date"] = due
|
||||
# In order to not lose any recurrence metadata for the task, we need to
|
||||
# ensure that we send the `due_string` param if the task has it set.
|
||||
# NOTE: It's ok to send stale data for non-recurring tasks. Any provided
|
||||
|
||||
@@ -35,4 +35,8 @@ class TouchlineSLZoneEntity(CoordinatorEntity[TouchlineSLModuleCoordinator]):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the device is available."""
|
||||
return super().available and self.zone_id in self.coordinator.data.zones
|
||||
return (
|
||||
super().available
|
||||
and self.zone_id in self.coordinator.data.zones
|
||||
and self.zone.alarm is None
|
||||
)
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/touchline_sl",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["pytouchlinesl==0.5.0"]
|
||||
"requirements": ["pytouchlinesl==0.6.0"]
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ TUYA_HVAC_TO_HA = {
|
||||
"heat": HVACMode.HEAT,
|
||||
"hot": HVACMode.HEAT,
|
||||
"manual": HVACMode.HEAT_COOL,
|
||||
"off": HVACMode.OFF,
|
||||
"wet": HVACMode.DRY,
|
||||
"wind": HVACMode.FAN_ONLY,
|
||||
}
|
||||
@@ -442,7 +443,9 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
if hvac_mode_wrapper:
|
||||
self._attr_hvac_modes = [HVACMode.OFF]
|
||||
for mode in hvac_mode_wrapper.options:
|
||||
self._attr_hvac_modes.append(HVACMode(mode))
|
||||
if mode != HVACMode.OFF:
|
||||
# OFF is always added first
|
||||
self._attr_hvac_modes.append(HVACMode(mode))
|
||||
|
||||
elif switch_wrapper:
|
||||
self._attr_hvac_modes = [
|
||||
|
||||
@@ -51,9 +51,14 @@ class DeviceWrapper[T]:
|
||||
) -> bool:
|
||||
"""Determine if the wrapper should skip an update.
|
||||
|
||||
The default is to always skip, unless overridden in subclasses.
|
||||
The default is to always skip if updated properties is given,
|
||||
unless overridden in subclasses.
|
||||
"""
|
||||
return True
|
||||
# If updated_status_properties is None, we should not skip,
|
||||
# as we don't have information on what was updated
|
||||
# This happens for example on online/offline updates, where
|
||||
# we still want to update the entity state
|
||||
return updated_status_properties is not None
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> T | None:
|
||||
"""Read device status and convert to a Home Assistant value."""
|
||||
@@ -88,9 +93,13 @@ class DPCodeWrapper(DeviceWrapper):
|
||||
By default, skip if updated_status_properties is given and
|
||||
does not include this dpcode.
|
||||
"""
|
||||
# If updated_status_properties is None, we should not skip,
|
||||
# as we don't have information on what was updated
|
||||
# This happens for example on online/offline updates, where
|
||||
# we still want to update the entity state
|
||||
return (
|
||||
updated_status_properties is None
|
||||
or self.dpcode not in updated_status_properties
|
||||
updated_status_properties is not None
|
||||
and self.dpcode not in updated_status_properties
|
||||
)
|
||||
|
||||
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
|
||||
@@ -250,6 +259,13 @@ class DPCodeDeltaIntegerWrapper(DPCodeIntegerWrapper):
|
||||
|
||||
Processes delta accumulation before determining if update should be skipped.
|
||||
"""
|
||||
# If updated_status_properties is None, we should not skip,
|
||||
# as we don't have information on what was updated
|
||||
# This happens for example on online/offline updates, where
|
||||
# we still want to update the entity state but we have nothing
|
||||
# to accumulate, so we return False to not skip the update
|
||||
if updated_status_properties is None:
|
||||
return False
|
||||
if (
|
||||
super().skip_update(device, updated_status_properties, dp_timestamps)
|
||||
or dp_timestamps is None
|
||||
|
||||
@@ -143,16 +143,6 @@ async def async_migrate_entry(
|
||||
"Migrating from version %s.%s", config_entry.version, config_entry.minor_version
|
||||
)
|
||||
|
||||
# This is the config entry migration for adding the new program selection
|
||||
# migrate from 1.x to 2.1
|
||||
if config_entry.version < 2:
|
||||
# clean the velbusCache
|
||||
cache_path = hass.config.path(
|
||||
STORAGE_DIR, f"velbuscache-{config_entry.entry_id}/"
|
||||
)
|
||||
if os.path.isdir(cache_path):
|
||||
await hass.async_add_executor_job(shutil.rmtree, cache_path)
|
||||
|
||||
# This is the config entry migration for swapping the usb unique id to the serial number
|
||||
# migrate from 2.1 to 2.2
|
||||
if (
|
||||
@@ -166,8 +156,20 @@ async def async_migrate_entry(
|
||||
if len(parts) == 4:
|
||||
hass.config_entries.async_update_entry(config_entry, unique_id=parts[1])
|
||||
|
||||
# This is the config entry migration for adding the new program selection
|
||||
# migrate from < 2 to 2.1
|
||||
# This is the config entry migration for adding the new properties
|
||||
# migrate from < 3 to 3.2
|
||||
if config_entry.version < 3:
|
||||
# clean the velbusCache
|
||||
cache_path = hass.config.path(
|
||||
STORAGE_DIR, f"velbuscache-{config_entry.entry_id}/"
|
||||
)
|
||||
if os.path.isdir(cache_path):
|
||||
await hass.async_add_executor_job(shutil.rmtree, cache_path)
|
||||
|
||||
# update the config entry
|
||||
hass.config_entries.async_update_entry(config_entry, version=2, minor_version=2)
|
||||
hass.config_entries.async_update_entry(config_entry, version=3, minor_version=2)
|
||||
|
||||
_LOGGER.error(
|
||||
"Migration to version %s.%s successful",
|
||||
|
||||
@@ -36,7 +36,7 @@ class InvalidVlpFile(HomeAssistantError):
|
||||
class VelbusConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow."""
|
||||
|
||||
VERSION = 2
|
||||
VERSION = 3
|
||||
MINOR_VERSION = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import _LOGGER
|
||||
from .coordinator import VodafoneConfigEntry, VodafoneStationRouter
|
||||
@@ -75,9 +76,11 @@ class VodafoneGuestWifiQRImage(
|
||||
self.entity_description = description
|
||||
self._attr_device_info = coordinator.device_info
|
||||
self._attr_unique_id = f"{coordinator.serial_number}-{description.key}-qr-code"
|
||||
self._cached_qr_code: bytes | None = None
|
||||
|
||||
async def async_image(self) -> bytes | None:
|
||||
"""Return QR code image bytes."""
|
||||
@property
|
||||
def _qr_code(self) -> bytes:
|
||||
"""Return QR code bytes."""
|
||||
qr_code = cast(
|
||||
BytesIO,
|
||||
self.coordinator.data.wifi[WIFI_DATA][self.entity_description.key][
|
||||
@@ -85,3 +88,24 @@ class VodafoneGuestWifiQRImage(
|
||||
],
|
||||
)
|
||||
return qr_code.getvalue()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Set the update time."""
|
||||
self._attr_image_last_updated = dt_util.utcnow()
|
||||
await super().async_added_to_hass()
|
||||
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator.
|
||||
|
||||
If the coordinator has updated the QR code, we can update the image.
|
||||
"""
|
||||
qr_code = self._qr_code
|
||||
if self._cached_qr_code != qr_code:
|
||||
self._cached_qr_code = qr_code
|
||||
self._attr_image_last_updated = dt_util.utcnow()
|
||||
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
async def async_image(self) -> bytes | None:
|
||||
"""Return QR code image."""
|
||||
return self._qr_code
|
||||
|
||||
@@ -4,7 +4,6 @@ from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from httpx import AsyncClient
|
||||
from pythonxbox.api.client import XboxLiveClient
|
||||
from pythonxbox.authentication.manager import AuthenticationManager
|
||||
from pythonxbox.authentication.models import OAuth2TokenResponse
|
||||
@@ -20,6 +19,7 @@ from homeassistant.config_entries import (
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
@@ -67,14 +67,14 @@ class OAuth2FlowHandler(
|
||||
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
|
||||
"""Create an entry for the flow."""
|
||||
|
||||
async with AsyncClient() as session:
|
||||
auth = AuthenticationManager(session, "", "", "")
|
||||
auth.oauth = OAuth2TokenResponse(**data["token"])
|
||||
await auth.refresh_tokens()
|
||||
session = get_async_client(self.hass)
|
||||
auth = AuthenticationManager(session, "", "", "")
|
||||
auth.oauth = OAuth2TokenResponse(**data["token"])
|
||||
await auth.refresh_tokens()
|
||||
|
||||
client = XboxLiveClient(auth)
|
||||
client = XboxLiveClient(auth)
|
||||
|
||||
me = await client.people.get_friends_by_xuid(client.xuid)
|
||||
me = await client.people.get_friends_by_xuid(client.xuid)
|
||||
|
||||
await self.async_set_unique_id(client.xuid)
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pyasn1", "slixmpp"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["slixmpp==1.12.0", "emoji==2.8.0"]
|
||||
"requirements": ["slixmpp==1.13.2", "emoji==2.8.0"]
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ SENSOR_DESCRIPTIONS: tuple[YardianSensorEntityDescription, ...] = (
|
||||
YardianSensorEntityDescription(
|
||||
key="active_zone_count",
|
||||
translation_key="active_zone_count",
|
||||
native_unit_of_measurement="zones",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda coordinator: len(coordinator.data.active_zones),
|
||||
),
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"sensor": {
|
||||
"active_zone_count": {
|
||||
"name": "Active zones",
|
||||
"unit_of_measurement": "Zones"
|
||||
"unit_of_measurement": "zones"
|
||||
},
|
||||
"rain_delay": {
|
||||
"name": "Rain delay"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"universal_silabs_flasher",
|
||||
"serialx"
|
||||
],
|
||||
"requirements": ["zha==0.0.89", "serialx==0.6.2"],
|
||||
"requirements": ["zha==0.0.90", "serialx==0.6.2"],
|
||||
"usb": [
|
||||
{
|
||||
"description": "*2652*",
|
||||
|
||||
@@ -283,7 +283,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
|
||||
return UnitOfTemperature.CELSIUS
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Return hvac operation ie. heat, cool mode."""
|
||||
if self._current_mode is None:
|
||||
# Thermostat(valve) with no support for setting
|
||||
@@ -292,7 +292,10 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
|
||||
if self._current_mode.value is None:
|
||||
# guard missing value
|
||||
return HVACMode.HEAT
|
||||
return ZW_HVAC_MODE_MAP.get(int(self._current_mode.value), HVACMode.HEAT_COOL)
|
||||
mode = ZW_HVAC_MODE_MAP.get(int(self._current_mode.value))
|
||||
if mode is not None and mode not in self._hvac_modes:
|
||||
return None
|
||||
return mode
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
@@ -548,12 +551,17 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
|
||||
"""Set new target preset mode."""
|
||||
assert self._current_mode is not None
|
||||
if preset_mode == PRESET_NONE:
|
||||
# try to restore to the (translated) main hvac mode
|
||||
await self.async_set_hvac_mode(self.hvac_mode)
|
||||
# Try to restore to the (translated) main hvac mode.
|
||||
if (hvac_mode := self.hvac_mode) is None:
|
||||
# Current preset mode doesn't map to a supported HVAC mode.
|
||||
# Pick the first supported non-off mode.
|
||||
hvac_mode = next(
|
||||
mode for mode in self._hvac_modes if mode != HVACMode.OFF
|
||||
)
|
||||
await self.async_set_hvac_mode(hvac_mode)
|
||||
return
|
||||
preset_mode_value = self._hvac_presets.get(preset_mode)
|
||||
if preset_mode_value is None:
|
||||
raise ValueError(f"Received an invalid preset mode: {preset_mode}")
|
||||
|
||||
preset_mode_value = self._hvac_presets[preset_mode]
|
||||
|
||||
await self._async_set_value(self._current_mode, preset_mode_value)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2026
|
||||
MINOR_VERSION: Final = 2
|
||||
PATCH_VERSION: Final = "0"
|
||||
PATCH_VERSION: Final = "3"
|
||||
__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)
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from contextlib import suppress
|
||||
from functools import lru_cache
|
||||
from ipaddress import ip_address
|
||||
import socket
|
||||
from ssl import SSLContext
|
||||
import sys
|
||||
@@ -12,10 +14,11 @@ from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Self
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
from aiohttp import ClientMiddlewareType, hdrs, web
|
||||
from aiohttp.hdrs import CONTENT_TYPE, USER_AGENT
|
||||
from aiohttp.web_exceptions import HTTPBadGateway, HTTPGatewayTimeout
|
||||
from aiohttp_asyncmdnsresolver.api import AsyncDualMDNSResolver
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components import zeroconf
|
||||
@@ -25,6 +28,7 @@ from homeassistant.loader import bind_hass
|
||||
from homeassistant.util import ssl as ssl_util
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.json import json_loads
|
||||
from homeassistant.util.network import is_loopback
|
||||
|
||||
from .frame import warn_use
|
||||
from .json import json_dumps
|
||||
@@ -49,6 +53,92 @@ SERVER_SOFTWARE = (
|
||||
|
||||
WARN_CLOSE_MSG = "closes the Home Assistant aiohttp session"
|
||||
|
||||
_LOCALHOST = "localhost"
|
||||
_TRAILING_LOCAL_HOST = f".{_LOCALHOST}"
|
||||
|
||||
|
||||
class SSRFRedirectError(aiohttp.ClientError):
|
||||
"""SSRF redirect protection.
|
||||
|
||||
Raised when a redirect targets a blocked address (loopback or unspecified).
|
||||
"""
|
||||
|
||||
|
||||
async def _ssrf_redirect_middleware(
|
||||
request: aiohttp.ClientRequest,
|
||||
handler: aiohttp.ClientHandlerType,
|
||||
) -> aiohttp.ClientResponse:
|
||||
"""Block redirects from non-loopback origins to loopback targets."""
|
||||
resp = await handler(request)
|
||||
|
||||
# Return early if not a redirect or already loopback to allow loopback origins
|
||||
connector = request.session.connector
|
||||
if not (300 <= resp.status < 400) or await _async_is_blocked_host(
|
||||
request.url.host, connector
|
||||
):
|
||||
return resp
|
||||
|
||||
location = resp.headers.get(hdrs.LOCATION, "")
|
||||
if not location:
|
||||
return resp
|
||||
|
||||
redirect_url = URL(location)
|
||||
if not redirect_url.is_absolute():
|
||||
# Relative redirects stay on the same host - always safe
|
||||
return resp
|
||||
|
||||
host = redirect_url.host
|
||||
if await _async_is_blocked_host(host, connector):
|
||||
resp.close()
|
||||
raise SSRFRedirectError(
|
||||
f"Redirect from {request.url.host} to a blocked address"
|
||||
f" is not allowed: {host}"
|
||||
)
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _is_ssrf_address(address: str) -> bool:
|
||||
"""Check if an IP address is a potential SSRF target.
|
||||
|
||||
Returns True for loopback and unspecified addresses.
|
||||
"""
|
||||
ip = ip_address(address)
|
||||
return is_loopback(ip) or ip.is_unspecified
|
||||
|
||||
|
||||
async def _async_is_blocked_host(
|
||||
host: str | None, connector: aiohttp.BaseConnector | None
|
||||
) -> bool:
|
||||
"""Check if a host is blocked by hostname or by resolved IP.
|
||||
|
||||
First does a fast sync check on the hostname string, then resolves
|
||||
the hostname via the connector and checks each resolved IP address.
|
||||
"""
|
||||
if not host:
|
||||
return False
|
||||
|
||||
# Strip FQDN trailing dot (RFC 1035) since yarl preserves it,
|
||||
# preventing an attacker from bypassing the check with "localhost."
|
||||
stripped_host = host.strip().removesuffix(".")
|
||||
if stripped_host == _LOCALHOST or stripped_host.endswith(_TRAILING_LOCAL_HOST):
|
||||
return True
|
||||
|
||||
with suppress(ValueError):
|
||||
return _is_ssrf_address(host)
|
||||
|
||||
if not isinstance(connector, HomeAssistantTCPConnector):
|
||||
return False
|
||||
|
||||
try:
|
||||
results = await connector.async_resolve_host(host)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
return any(_is_ssrf_address(result["host"]) for result in results)
|
||||
|
||||
|
||||
#
|
||||
# The default connection limit of 100 meant that you could only have
|
||||
# 100 concurrent connections.
|
||||
@@ -191,10 +281,16 @@ def _async_create_clientsession(
|
||||
**kwargs: Any,
|
||||
) -> aiohttp.ClientSession:
|
||||
"""Create a new ClientSession with kwargs, i.e. for cookies."""
|
||||
middlewares: Sequence[ClientMiddlewareType] = (
|
||||
_ssrf_redirect_middleware,
|
||||
*kwargs.pop("middlewares", ()),
|
||||
)
|
||||
|
||||
clientsession = aiohttp.ClientSession(
|
||||
connector=_async_get_connector(hass, verify_ssl, family, ssl_cipher),
|
||||
json_serialize=json_dumps,
|
||||
response_class=HassClientResponse,
|
||||
middlewares=middlewares,
|
||||
**kwargs,
|
||||
)
|
||||
# Prevent packages accidentally overriding our default headers
|
||||
@@ -343,6 +439,10 @@ class HomeAssistantTCPConnector(aiohttp.TCPConnector):
|
||||
# abort transport after 60 seconds (cleanup broken connections)
|
||||
_cleanup_closed_period = 60.0
|
||||
|
||||
async def async_resolve_host(self, host: str) -> list[aiohttp.abc.ResolveResult]:
|
||||
"""Resolve a host to a list of addresses."""
|
||||
return await self._resolve_host(host, 0)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_get_connector(
|
||||
|
||||
@@ -42,6 +42,7 @@ from . import device_registry as dr, entity_registry as er, service, translation
|
||||
from .deprecation import deprecated_function
|
||||
from .entity_registry import EntityRegistry, RegistryEntryDisabler, RegistryEntryHider
|
||||
from .event import async_call_later
|
||||
from .frame import report_usage
|
||||
from .issue_registry import IssueSeverity, async_create_issue
|
||||
from .typing import UNDEFINED, ConfigType, DiscoveryInfoType, VolDictType, VolSchemaType
|
||||
|
||||
@@ -822,13 +823,28 @@ class EntityPlatform:
|
||||
# An entity may suggest the entity_id by setting entity_id itself
|
||||
if not hasattr(entity, "internal_integration_suggested_object_id"):
|
||||
if entity.entity_id is not None and not valid_entity_id(entity.entity_id):
|
||||
if entity.unique_id is not None:
|
||||
report_usage(
|
||||
f"sets an invalid entity ID: '{entity.entity_id}'. "
|
||||
"In most cases, entities should not set entity_id,"
|
||||
" but if they do, it should be a valid entity ID.",
|
||||
integration_domain=self.platform_name,
|
||||
breaks_in_ha_version="2027.2.0",
|
||||
)
|
||||
else:
|
||||
entity.add_to_platform_abort()
|
||||
raise HomeAssistantError(f"Invalid entity ID: {entity.entity_id}")
|
||||
try:
|
||||
entity.internal_integration_suggested_object_id = (
|
||||
split_entity_id(entity.entity_id)[1]
|
||||
if entity.entity_id is not None
|
||||
else None
|
||||
)
|
||||
except ValueError:
|
||||
entity.add_to_platform_abort()
|
||||
raise HomeAssistantError(f"Invalid entity ID: {entity.entity_id}")
|
||||
entity.internal_integration_suggested_object_id = (
|
||||
split_entity_id(entity.entity_id)[1]
|
||||
if entity.entity_id is not None
|
||||
else None
|
||||
)
|
||||
raise HomeAssistantError(
|
||||
f"Invalid entity ID: {entity.entity_id}"
|
||||
) from None
|
||||
|
||||
# Get entity_id from unique ID registration
|
||||
if entity.unique_id is not None:
|
||||
|
||||
@@ -29,7 +29,7 @@ cached-ipaddress==1.0.1
|
||||
certifi>=2021.5.30
|
||||
ciso8601==2.3.3
|
||||
cronsim==2.7
|
||||
cryptography==46.0.2
|
||||
cryptography==46.0.5
|
||||
dbus-fast==3.1.2
|
||||
file-read-backwards==2.0.0
|
||||
fnv-hash-fast==1.6.0
|
||||
@@ -89,9 +89,9 @@ httplib2>=0.19.0
|
||||
# gRPC is an implicit dependency that we want to make explicit so we manage
|
||||
# upgrades intentionally. It is a large package to build from source and we
|
||||
# want to ensure we have wheels built.
|
||||
grpcio==1.75.1
|
||||
grpcio-status==1.75.1
|
||||
grpcio-reflection==1.75.1
|
||||
grpcio==1.78.0
|
||||
grpcio-status==1.78.0
|
||||
grpcio-reflection==1.78.0
|
||||
|
||||
# This is a old unmaintained library and is replaced with pycryptodome
|
||||
pycrypto==1000000000.0.0
|
||||
@@ -235,3 +235,6 @@ aiomqtt>=2.5.0
|
||||
# used by sharkiq==1.5.0
|
||||
# https://github.com/auth0/auth0-python/releases/tag/5.0.0
|
||||
auth0-python<5.0
|
||||
|
||||
# Setuptools >=82.0.0 doesn't contain pkg_resources anymore
|
||||
setuptools<82.0.0
|
||||
|
||||
@@ -28,6 +28,7 @@ class AsyncIteratorReader:
|
||||
) -> None:
|
||||
"""Initialize the wrapper."""
|
||||
self._aborted = False
|
||||
self._exhausted = False
|
||||
self._loop = loop
|
||||
self._stream = stream
|
||||
self._buffer: bytes | None = None
|
||||
@@ -51,6 +52,8 @@ class AsyncIteratorReader:
|
||||
"""
|
||||
result = bytearray()
|
||||
while n < 0 or len(result) < n:
|
||||
if self._exhausted:
|
||||
break
|
||||
if not self._buffer:
|
||||
self._next_future = asyncio.run_coroutine_threadsafe(
|
||||
self._next(), self._loop
|
||||
@@ -65,6 +68,7 @@ class AsyncIteratorReader:
|
||||
self._pos = 0
|
||||
if not self._buffer:
|
||||
# The stream is exhausted
|
||||
self._exhausted = True
|
||||
break
|
||||
chunk = self._buffer[self._pos : self._pos + n]
|
||||
result.extend(chunk)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2026.2.0"
|
||||
version = "2026.2.3"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
@@ -58,7 +58,7 @@ dependencies = [
|
||||
"lru-dict==1.3.0",
|
||||
"PyJWT==2.10.1",
|
||||
# PyJWT has loose dependency. We want the latest one.
|
||||
"cryptography==46.0.2",
|
||||
"cryptography==46.0.5",
|
||||
"Pillow==12.0.0",
|
||||
"propcache==0.4.1",
|
||||
"pyOpenSSL==25.3.0",
|
||||
|
||||
2
requirements.txt
generated
2
requirements.txt
generated
@@ -21,7 +21,7 @@ bcrypt==5.0.0
|
||||
certifi>=2021.5.30
|
||||
ciso8601==2.3.3
|
||||
cronsim==2.7
|
||||
cryptography==46.0.2
|
||||
cryptography==46.0.5
|
||||
fnv-hash-fast==1.6.0
|
||||
ha-ffmpeg==3.2.2
|
||||
hass-nabucasa==1.12.0
|
||||
|
||||
46
requirements_all.txt
generated
46
requirements_all.txt
generated
@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
|
||||
aioairzone==1.0.5
|
||||
|
||||
# homeassistant.components.alexa_devices
|
||||
aioamazondevices==11.1.1
|
||||
aioamazondevices==11.1.3
|
||||
|
||||
# homeassistant.components.ambient_network
|
||||
# homeassistant.components.ambient_station
|
||||
@@ -209,7 +209,7 @@ aioaseko==1.0.0
|
||||
aioasuswrt==1.5.4
|
||||
|
||||
# homeassistant.components.husqvarna_automower
|
||||
aioautomower==2.7.1
|
||||
aioautomower==2.7.3
|
||||
|
||||
# homeassistant.components.azure_devops
|
||||
aioazuredevops==2.2.2
|
||||
@@ -293,7 +293,7 @@ aiohue==4.8.0
|
||||
aioimaplib==2.0.1
|
||||
|
||||
# homeassistant.components.immich
|
||||
aioimmich==0.11.1
|
||||
aioimmich==0.12.0
|
||||
|
||||
# homeassistant.components.apache_kafka
|
||||
aiokafka==0.10.0
|
||||
@@ -797,7 +797,7 @@ deluge-client==1.10.2
|
||||
demetriek==1.3.0
|
||||
|
||||
# homeassistant.components.denonavr
|
||||
denonavr==1.3.1
|
||||
denonavr==1.3.2
|
||||
|
||||
# homeassistant.components.devialet
|
||||
devialet==1.5.7
|
||||
@@ -854,7 +854,7 @@ ecoaliface==0.4.0
|
||||
egauge-async==0.4.0
|
||||
|
||||
# homeassistant.components.eheimdigital
|
||||
eheimdigital==1.5.0
|
||||
eheimdigital==1.6.0
|
||||
|
||||
# homeassistant.components.ekeybionyx
|
||||
ekey-bionyxpy==1.0.1
|
||||
@@ -926,7 +926,7 @@ eq3btsmart==2.3.0
|
||||
esphome-dashboard-api==1.3.0
|
||||
|
||||
# homeassistant.components.essent
|
||||
essent-dynamic-pricing==0.2.7
|
||||
essent-dynamic-pricing==0.3.1
|
||||
|
||||
# homeassistant.components.netgear_lte
|
||||
eternalegypt==0.0.18
|
||||
@@ -938,7 +938,7 @@ eufylife-ble-client==0.1.8
|
||||
# evdev==1.6.1
|
||||
|
||||
# homeassistant.components.evohome
|
||||
evohome-async==1.0.6
|
||||
evohome-async==1.1.3
|
||||
|
||||
# homeassistant.components.bryant_evolution
|
||||
evolutionhttp==0.0.18
|
||||
@@ -1105,7 +1105,7 @@ google-nest-sdm==9.1.2
|
||||
google-photos-library-api==0.12.1
|
||||
|
||||
# homeassistant.components.google_air_quality
|
||||
google_air_quality_api==3.0.0
|
||||
google_air_quality_api==3.0.1
|
||||
|
||||
# homeassistant.components.slide
|
||||
# homeassistant.components.slide_local
|
||||
@@ -1258,7 +1258,7 @@ ibeacon-ble==1.2.0
|
||||
# homeassistant.components.local_calendar
|
||||
# homeassistant.components.local_todo
|
||||
# homeassistant.components.remote_calendar
|
||||
ical==12.1.3
|
||||
ical==13.2.0
|
||||
|
||||
# homeassistant.components.caldav
|
||||
icalendar==6.3.1
|
||||
@@ -1303,7 +1303,7 @@ inkbird-ble==1.1.1
|
||||
insteon-frontend-home-assistant==0.6.1
|
||||
|
||||
# homeassistant.components.intellifire
|
||||
intellifire4py==4.2.1
|
||||
intellifire4py==4.3.1
|
||||
|
||||
# homeassistant.components.iometer
|
||||
iometer==0.3.0
|
||||
@@ -1397,7 +1397,7 @@ libpyfoscamcgi==0.0.9
|
||||
libpyvivotek==0.6.1
|
||||
|
||||
# homeassistant.components.libre_hardware_monitor
|
||||
librehardwaremonitor-api==1.8.4
|
||||
librehardwaremonitor-api==1.9.1
|
||||
|
||||
# homeassistant.components.mikrotik
|
||||
librouteros==3.2.0
|
||||
@@ -1653,7 +1653,7 @@ omnilogic==0.4.5
|
||||
ondilo==0.5.0
|
||||
|
||||
# homeassistant.components.onedrive
|
||||
onedrive-personal-sdk==0.1.1
|
||||
onedrive-personal-sdk==0.1.2
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif-zeep-async==4.0.4
|
||||
@@ -1974,7 +1974,7 @@ pycsspeechtts==1.0.8
|
||||
pycync==0.5.0
|
||||
|
||||
# homeassistant.components.daikin
|
||||
pydaikin==2.17.1
|
||||
pydaikin==2.17.2
|
||||
|
||||
# homeassistant.components.danfoss_air
|
||||
pydanfossair==0.1.0
|
||||
@@ -2032,7 +2032,7 @@ pyegps==0.2.5
|
||||
pyemoncms==0.1.3
|
||||
|
||||
# homeassistant.components.enphase_envoy
|
||||
pyenphase==2.4.3
|
||||
pyenphase==2.4.5
|
||||
|
||||
# homeassistant.components.envisalink
|
||||
pyenvisalink==4.7
|
||||
@@ -2251,7 +2251,7 @@ pynina==1.0.2
|
||||
pynintendoauth==1.0.2
|
||||
|
||||
# homeassistant.components.nintendo_parental_controls
|
||||
pynintendoparental==2.3.2
|
||||
pynintendoparental==2.3.2.1
|
||||
|
||||
# homeassistant.components.nobo_hub
|
||||
pynobo==1.8.1
|
||||
@@ -2316,7 +2316,7 @@ pypaperless==4.1.1
|
||||
pypca==0.0.7
|
||||
|
||||
# homeassistant.components.lcn
|
||||
pypck==0.9.9
|
||||
pypck==0.9.11
|
||||
|
||||
# homeassistant.components.pglab
|
||||
pypglab==0.0.5
|
||||
@@ -2358,7 +2358,7 @@ pyqwikswitch==0.93
|
||||
pyrail==0.4.1
|
||||
|
||||
# homeassistant.components.rainbird
|
||||
pyrainbird==6.0.1
|
||||
pyrainbird==6.0.5
|
||||
|
||||
# homeassistant.components.playstation_network
|
||||
pyrate-limiter==3.9.0
|
||||
@@ -2434,7 +2434,7 @@ pysmappee==0.2.29
|
||||
pysmarlaapi==0.9.3
|
||||
|
||||
# homeassistant.components.smartthings
|
||||
pysmartthings==3.5.1
|
||||
pysmartthings==3.5.3
|
||||
|
||||
# homeassistant.components.smarty
|
||||
pysmarty2==0.10.3
|
||||
@@ -2597,7 +2597,7 @@ python-ripple-api==0.0.3
|
||||
python-roborock==4.8.0
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.46
|
||||
python-smarttub==0.0.47
|
||||
|
||||
# homeassistant.components.snoo
|
||||
python-snoo==0.8.3
|
||||
@@ -2636,7 +2636,7 @@ pytomorrowio==0.3.6
|
||||
pytouchline_extended==0.4.5
|
||||
|
||||
# homeassistant.components.touchline_sl
|
||||
pytouchlinesl==0.5.0
|
||||
pytouchlinesl==0.6.0
|
||||
|
||||
# homeassistant.components.traccar
|
||||
# homeassistant.components.traccar_server
|
||||
@@ -2754,7 +2754,7 @@ renault-api==0.5.3
|
||||
renson-endura-delta==1.7.2
|
||||
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.18.2
|
||||
reolink-aio==0.19.0
|
||||
|
||||
# homeassistant.components.idteck_prox
|
||||
rfk101py==0.0.1
|
||||
@@ -2891,7 +2891,7 @@ skyboxremote==0.0.6
|
||||
slack_sdk==3.33.4
|
||||
|
||||
# homeassistant.components.xmpp
|
||||
slixmpp==1.12.0
|
||||
slixmpp==1.13.2
|
||||
|
||||
# homeassistant.components.smart_meter_texas
|
||||
smart-meter-texas==0.5.5
|
||||
@@ -3296,7 +3296,7 @@ zeroconf==0.148.0
|
||||
zeversolar==0.3.2
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha==0.0.89
|
||||
zha==0.0.90
|
||||
|
||||
# homeassistant.components.zhong_hong
|
||||
zhong-hong-hvac==1.0.13
|
||||
|
||||
44
requirements_test_all.txt
generated
44
requirements_test_all.txt
generated
@@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2
|
||||
aioairzone==1.0.5
|
||||
|
||||
# homeassistant.components.alexa_devices
|
||||
aioamazondevices==11.1.1
|
||||
aioamazondevices==11.1.3
|
||||
|
||||
# homeassistant.components.ambient_network
|
||||
# homeassistant.components.ambient_station
|
||||
@@ -200,7 +200,7 @@ aioaseko==1.0.0
|
||||
aioasuswrt==1.5.4
|
||||
|
||||
# homeassistant.components.husqvarna_automower
|
||||
aioautomower==2.7.1
|
||||
aioautomower==2.7.3
|
||||
|
||||
# homeassistant.components.azure_devops
|
||||
aioazuredevops==2.2.2
|
||||
@@ -281,7 +281,7 @@ aiohue==4.8.0
|
||||
aioimaplib==2.0.1
|
||||
|
||||
# homeassistant.components.immich
|
||||
aioimmich==0.11.1
|
||||
aioimmich==0.12.0
|
||||
|
||||
# homeassistant.components.apache_kafka
|
||||
aiokafka==0.10.0
|
||||
@@ -706,7 +706,7 @@ deluge-client==1.10.2
|
||||
demetriek==1.3.0
|
||||
|
||||
# homeassistant.components.denonavr
|
||||
denonavr==1.3.1
|
||||
denonavr==1.3.2
|
||||
|
||||
# homeassistant.components.devialet
|
||||
devialet==1.5.7
|
||||
@@ -754,7 +754,7 @@ easyenergy==2.2.0
|
||||
egauge-async==0.4.0
|
||||
|
||||
# homeassistant.components.eheimdigital
|
||||
eheimdigital==1.5.0
|
||||
eheimdigital==1.6.0
|
||||
|
||||
# homeassistant.components.ekeybionyx
|
||||
ekey-bionyxpy==1.0.1
|
||||
@@ -817,7 +817,7 @@ eq3btsmart==2.3.0
|
||||
esphome-dashboard-api==1.3.0
|
||||
|
||||
# homeassistant.components.essent
|
||||
essent-dynamic-pricing==0.2.7
|
||||
essent-dynamic-pricing==0.3.1
|
||||
|
||||
# homeassistant.components.netgear_lte
|
||||
eternalegypt==0.0.18
|
||||
@@ -826,7 +826,7 @@ eternalegypt==0.0.18
|
||||
eufylife-ble-client==0.1.8
|
||||
|
||||
# homeassistant.components.evohome
|
||||
evohome-async==1.0.6
|
||||
evohome-async==1.1.3
|
||||
|
||||
# homeassistant.components.bryant_evolution
|
||||
evolutionhttp==0.0.18
|
||||
@@ -981,7 +981,7 @@ google-nest-sdm==9.1.2
|
||||
google-photos-library-api==0.12.1
|
||||
|
||||
# homeassistant.components.google_air_quality
|
||||
google_air_quality_api==3.0.0
|
||||
google_air_quality_api==3.0.1
|
||||
|
||||
# homeassistant.components.slide
|
||||
# homeassistant.components.slide_local
|
||||
@@ -1110,7 +1110,7 @@ ibeacon-ble==1.2.0
|
||||
# homeassistant.components.local_calendar
|
||||
# homeassistant.components.local_todo
|
||||
# homeassistant.components.remote_calendar
|
||||
ical==12.1.3
|
||||
ical==13.2.0
|
||||
|
||||
# homeassistant.components.caldav
|
||||
icalendar==6.3.1
|
||||
@@ -1149,7 +1149,7 @@ inkbird-ble==1.1.1
|
||||
insteon-frontend-home-assistant==0.6.1
|
||||
|
||||
# homeassistant.components.intellifire
|
||||
intellifire4py==4.2.1
|
||||
intellifire4py==4.3.1
|
||||
|
||||
# homeassistant.components.iometer
|
||||
iometer==0.3.0
|
||||
@@ -1228,7 +1228,7 @@ libpyfoscamcgi==0.0.9
|
||||
libpyvivotek==0.6.1
|
||||
|
||||
# homeassistant.components.libre_hardware_monitor
|
||||
librehardwaremonitor-api==1.8.4
|
||||
librehardwaremonitor-api==1.9.1
|
||||
|
||||
# homeassistant.components.mikrotik
|
||||
librouteros==3.2.0
|
||||
@@ -1436,7 +1436,7 @@ omnilogic==0.4.5
|
||||
ondilo==0.5.0
|
||||
|
||||
# homeassistant.components.onedrive
|
||||
onedrive-personal-sdk==0.1.1
|
||||
onedrive-personal-sdk==0.1.2
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif-zeep-async==4.0.4
|
||||
@@ -1687,7 +1687,7 @@ pycsspeechtts==1.0.8
|
||||
pycync==0.5.0
|
||||
|
||||
# homeassistant.components.daikin
|
||||
pydaikin==2.17.1
|
||||
pydaikin==2.17.2
|
||||
|
||||
# homeassistant.components.deako
|
||||
pydeako==0.6.0
|
||||
@@ -1730,7 +1730,7 @@ pyegps==0.2.5
|
||||
pyemoncms==0.1.3
|
||||
|
||||
# homeassistant.components.enphase_envoy
|
||||
pyenphase==2.4.3
|
||||
pyenphase==2.4.5
|
||||
|
||||
# homeassistant.components.everlights
|
||||
pyeverlights==0.1.0
|
||||
@@ -1907,7 +1907,7 @@ pynina==1.0.2
|
||||
pynintendoauth==1.0.2
|
||||
|
||||
# homeassistant.components.nintendo_parental_controls
|
||||
pynintendoparental==2.3.2
|
||||
pynintendoparental==2.3.2.1
|
||||
|
||||
# homeassistant.components.nobo_hub
|
||||
pynobo==1.8.1
|
||||
@@ -1963,7 +1963,7 @@ pypalazzetti==0.1.20
|
||||
pypaperless==4.1.1
|
||||
|
||||
# homeassistant.components.lcn
|
||||
pypck==0.9.9
|
||||
pypck==0.9.11
|
||||
|
||||
# homeassistant.components.pglab
|
||||
pypglab==0.0.5
|
||||
@@ -2002,7 +2002,7 @@ pyqwikswitch==0.93
|
||||
pyrail==0.4.1
|
||||
|
||||
# homeassistant.components.rainbird
|
||||
pyrainbird==6.0.1
|
||||
pyrainbird==6.0.5
|
||||
|
||||
# homeassistant.components.playstation_network
|
||||
pyrate-limiter==3.9.0
|
||||
@@ -2060,7 +2060,7 @@ pysmappee==0.2.29
|
||||
pysmarlaapi==0.9.3
|
||||
|
||||
# homeassistant.components.smartthings
|
||||
pysmartthings==3.5.1
|
||||
pysmartthings==3.5.3
|
||||
|
||||
# homeassistant.components.smarty
|
||||
pysmarty2==0.10.3
|
||||
@@ -2187,7 +2187,7 @@ python-rabbitair==0.0.8
|
||||
python-roborock==4.8.0
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.46
|
||||
python-smarttub==0.0.47
|
||||
|
||||
# homeassistant.components.snoo
|
||||
python-snoo==0.8.3
|
||||
@@ -2217,7 +2217,7 @@ pytile==2024.12.0
|
||||
pytomorrowio==0.3.6
|
||||
|
||||
# homeassistant.components.touchline_sl
|
||||
pytouchlinesl==0.5.0
|
||||
pytouchlinesl==0.6.0
|
||||
|
||||
# homeassistant.components.traccar
|
||||
# homeassistant.components.traccar_server
|
||||
@@ -2320,7 +2320,7 @@ renault-api==0.5.3
|
||||
renson-endura-delta==1.7.2
|
||||
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.18.2
|
||||
reolink-aio==0.19.0
|
||||
|
||||
# homeassistant.components.rflink
|
||||
rflink==0.0.67
|
||||
@@ -2763,7 +2763,7 @@ zeroconf==0.148.0
|
||||
zeversolar==0.3.2
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha==0.0.89
|
||||
zha==0.0.90
|
||||
|
||||
# homeassistant.components.zwave_js
|
||||
zwave-js-server-python==0.68.0
|
||||
|
||||
@@ -79,9 +79,9 @@ httplib2>=0.19.0
|
||||
# gRPC is an implicit dependency that we want to make explicit so we manage
|
||||
# upgrades intentionally. It is a large package to build from source and we
|
||||
# want to ensure we have wheels built.
|
||||
grpcio==1.75.1
|
||||
grpcio-status==1.75.1
|
||||
grpcio-reflection==1.75.1
|
||||
grpcio==1.78.0
|
||||
grpcio-status==1.78.0
|
||||
grpcio-reflection==1.78.0
|
||||
|
||||
# This is a old unmaintained library and is replaced with pycryptodome
|
||||
pycrypto==1000000000.0.0
|
||||
@@ -225,6 +225,9 @@ aiomqtt>=2.5.0
|
||||
# used by sharkiq==1.5.0
|
||||
# https://github.com/auth0/auth0-python/releases/tag/5.0.0
|
||||
auth0-python<5.0
|
||||
|
||||
# Setuptools >=82.0.0 doesn't contain pkg_resources anymore
|
||||
setuptools<82.0.0
|
||||
"""
|
||||
|
||||
GENERATED_MESSAGE = (
|
||||
|
||||
@@ -203,11 +203,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
|
||||
"sense": {"sense-energy": {"async-timeout"}},
|
||||
"slimproto": {"aioslimproto": {"async-timeout"}},
|
||||
"surepetcare": {"surepy": {"async-timeout"}},
|
||||
"tami4": {
|
||||
# https://github.com/SeleniumHQ/selenium/issues/16943
|
||||
# tami4 > selenium > types*
|
||||
"selenium": {"types-certifi", "types-urllib3"},
|
||||
},
|
||||
"travisci": {
|
||||
# https://github.com/menegazzo/travispy seems to be unmaintained
|
||||
# and unused https://www.home-assistant.io/integrations/travisci
|
||||
|
||||
@@ -955,3 +955,199 @@ async def test_upload_cancelled(
|
||||
# CancelledError propagates up and causes a 500 error
|
||||
assert resp.status == 500
|
||||
assert any("cancelled" in msg for msg in caplog.messages)
|
||||
|
||||
|
||||
async def test_metadata_download_timeout_during_list(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that metadata download timeout during list is handled gracefully."""
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
mock_metadata = Mock()
|
||||
mock_metadata.file_name = "testprefix/slow.metadata.json"
|
||||
|
||||
mock_tar = Mock()
|
||||
mock_tar.file_name = "testprefix/slow.tar"
|
||||
mock_tar.size = TEST_BACKUP.size
|
||||
|
||||
def mock_ls(_self, _prefix=""):
|
||||
return iter([(mock_metadata, None), (mock_tar, None)])
|
||||
|
||||
with (
|
||||
patch.object(BucketSimulator, "ls", mock_ls),
|
||||
patch(
|
||||
"homeassistant.components.backblaze_b2.backup.asyncio.wait_for",
|
||||
side_effect=TimeoutError,
|
||||
),
|
||||
caplog.at_level(logging.WARNING),
|
||||
):
|
||||
await client.send_json_auto_id({"type": "backup/info"})
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
# The backup should not appear in the list due to timeout
|
||||
assert len(response["result"]["backups"]) == 0
|
||||
assert any("Timeout downloading metadata file" in msg for msg in caplog.messages)
|
||||
|
||||
|
||||
async def test_metadata_download_timeout_during_find_by_id(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that metadata download timeout during find by ID is handled gracefully."""
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
mock_metadata = Mock()
|
||||
mock_metadata.file_name = f"testprefix/{TEST_BACKUP.backup_id}.metadata.json"
|
||||
|
||||
mock_tar = Mock()
|
||||
mock_tar.file_name = f"testprefix/{TEST_BACKUP.backup_id}.tar"
|
||||
mock_tar.size = TEST_BACKUP.size
|
||||
|
||||
def mock_ls(_self, _prefix=""):
|
||||
return iter([(mock_metadata, None), (mock_tar, None)])
|
||||
|
||||
with (
|
||||
patch.object(BucketSimulator, "ls", mock_ls),
|
||||
patch(
|
||||
"homeassistant.components.backblaze_b2.backup.asyncio.wait_for",
|
||||
side_effect=TimeoutError,
|
||||
),
|
||||
caplog.at_level(logging.WARNING),
|
||||
):
|
||||
await client.send_json_auto_id(
|
||||
{"type": "backup/details", "backup_id": TEST_BACKUP.backup_id}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
# The backup should not be found due to timeout
|
||||
assert response["result"]["backup"] is None
|
||||
assert any(
|
||||
"Timeout downloading metadata file" in msg
|
||||
and "while searching for backup" in msg
|
||||
for msg in caplog.messages
|
||||
)
|
||||
|
||||
|
||||
async def test_metadata_timeout_does_not_block_healthy_backups(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that a timed out metadata download doesn't prevent listing other backups."""
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
mock_hanging_metadata = Mock()
|
||||
mock_hanging_metadata.file_name = "testprefix/hanging_backup.metadata.json"
|
||||
mock_hanging_metadata.download = Mock(side_effect=B2Error("SSL failure"))
|
||||
|
||||
mock_hanging_tar = Mock()
|
||||
mock_hanging_tar.file_name = "testprefix/hanging_backup.tar"
|
||||
mock_hanging_tar.size = 1000
|
||||
|
||||
mock_healthy_metadata = Mock()
|
||||
mock_healthy_metadata.file_name = (
|
||||
f"testprefix/{TEST_BACKUP.backup_id}.metadata.json"
|
||||
)
|
||||
mock_healthy_download = Mock()
|
||||
mock_healthy_response = Mock()
|
||||
mock_healthy_response.content = json.dumps(BACKUP_METADATA).encode()
|
||||
mock_healthy_download.response = mock_healthy_response
|
||||
mock_healthy_metadata.download = Mock(return_value=mock_healthy_download)
|
||||
|
||||
mock_healthy_tar = Mock()
|
||||
mock_healthy_tar.file_name = f"testprefix/{TEST_BACKUP.backup_id}.tar"
|
||||
mock_healthy_tar.size = TEST_BACKUP.size
|
||||
|
||||
def mock_ls(_self, _prefix=""):
|
||||
return iter(
|
||||
[
|
||||
(mock_hanging_metadata, None),
|
||||
(mock_hanging_tar, None),
|
||||
(mock_healthy_metadata, None),
|
||||
(mock_healthy_tar, None),
|
||||
]
|
||||
)
|
||||
|
||||
call_count = 0
|
||||
original_wait_for = asyncio.wait_for
|
||||
|
||||
async def wait_for_first_timeout(coro, *, timeout=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise TimeoutError
|
||||
return await original_wait_for(coro, timeout=timeout)
|
||||
|
||||
with (
|
||||
patch.object(BucketSimulator, "ls", mock_ls),
|
||||
patch(
|
||||
"homeassistant.components.backblaze_b2.backup.asyncio.wait_for",
|
||||
wait_for_first_timeout,
|
||||
),
|
||||
caplog.at_level(logging.WARNING),
|
||||
):
|
||||
await client.send_json_auto_id({"type": "backup/info"})
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
backups = response["result"]["backups"]
|
||||
assert len(backups) == 1
|
||||
assert backups[0]["backup_id"] == TEST_BACKUP.backup_id
|
||||
assert any("Timeout downloading metadata file" in msg for msg in caplog.messages)
|
||||
|
||||
|
||||
async def test_metadata_download_timeout_during_get_backup(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test timeout on metadata re-download after file is found."""
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
mock_metadata = Mock()
|
||||
mock_metadata.file_name = f"testprefix/{TEST_BACKUP.backup_id}.metadata.json"
|
||||
mock_download = Mock()
|
||||
mock_response = Mock()
|
||||
mock_response.content = json.dumps(BACKUP_METADATA).encode()
|
||||
mock_download.response = mock_response
|
||||
mock_metadata.download = Mock(return_value=mock_download)
|
||||
|
||||
mock_tar = Mock()
|
||||
mock_tar.file_name = f"testprefix/{TEST_BACKUP.backup_id}.tar"
|
||||
mock_tar.size = TEST_BACKUP.size
|
||||
|
||||
def mock_ls(_self, _prefix=""):
|
||||
return iter([(mock_metadata, None), (mock_tar, None)])
|
||||
|
||||
call_count = 0
|
||||
original_wait_for = asyncio.wait_for
|
||||
|
||||
async def wait_for_second_timeout(coro, *, timeout=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count >= 2:
|
||||
raise TimeoutError
|
||||
return await original_wait_for(coro, timeout=timeout)
|
||||
|
||||
with (
|
||||
patch.object(BucketSimulator, "ls", mock_ls),
|
||||
patch(
|
||||
"homeassistant.components.backblaze_b2.backup.asyncio.wait_for",
|
||||
wait_for_second_timeout,
|
||||
),
|
||||
patch("homeassistant.components.backblaze_b2.backup.CACHE_TTL", 0),
|
||||
):
|
||||
await client.send_json_auto_id(
|
||||
{"type": "backup/details", "backup_id": TEST_BACKUP.backup_id}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
assert (
|
||||
f"{DOMAIN}.{mock_config_entry.entry_id}" in response["result"]["agent_errors"]
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -223,9 +224,40 @@ async def test_prepare_chat_for_generation_passes_messages_through(
|
||||
) -> None:
|
||||
"""Test that prepared messages are forwarded unchanged."""
|
||||
chat_log = conversation.ChatLog(hass, "conversation-id")
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
conversation.AssistantContent(agent_id="agent", content="Ready")
|
||||
|
||||
chat_log.async_add_user_content(
|
||||
conversation.UserContent(content="What time is it?")
|
||||
)
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
conversation.AssistantContent(
|
||||
agent_id="agent",
|
||||
tool_calls=[
|
||||
llm.ToolInput(
|
||||
tool_name="HassGetCurrentTime",
|
||||
tool_args={},
|
||||
id="mock-tool-call-id",
|
||||
external=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
conversation.ToolResultContent(
|
||||
agent_id="agent",
|
||||
tool_call_id="mock-tool-call-id",
|
||||
tool_name="HassGetCurrentTime",
|
||||
tool_result={
|
||||
"speech": {"plain": {"speech": "12:00 PM", "extra_data": None}},
|
||||
"response_type": "action_done",
|
||||
"speech_slots": {"time": datetime.time(12, 0)},
|
||||
"data": {"targets": [], "success": [], "failed": []},
|
||||
},
|
||||
)
|
||||
)
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
conversation.AssistantContent(agent_id="agent", content="12:00 PM")
|
||||
)
|
||||
|
||||
messages = _convert_content_to_param(chat_log.content)
|
||||
|
||||
response = await cloud_entity._prepare_chat_for_generation(chat_log, messages)
|
||||
|
||||
@@ -367,6 +367,57 @@ async def test_agents_upload_network_failure(
|
||||
assert "Upload failed for cloudflare_r2" in caplog.text
|
||||
|
||||
|
||||
async def test_multipart_upload_consistent_part_sizes(
|
||||
hass: HomeAssistant,
|
||||
mock_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that multipart upload uses consistent part sizes.
|
||||
|
||||
S3/R2 requires all non-trailing parts to have the same size. This test
|
||||
verifies that varying chunk sizes still result in consistent part sizes.
|
||||
"""
|
||||
agent = R2BackupAgent(hass, mock_config_entry)
|
||||
|
||||
# simulate varying chunk data sizes
|
||||
# total data: 12 + 12 + 10 + 12 + 5 = 51 MiB
|
||||
chunk_sizes = [12, 12, 10, 12, 5] # in units of 1 MiB
|
||||
mib = 2**20
|
||||
|
||||
async def mock_stream():
|
||||
for size in chunk_sizes:
|
||||
yield b"x" * (size * mib)
|
||||
|
||||
async def open_stream():
|
||||
return mock_stream()
|
||||
|
||||
# Record the sizes of each uploaded part
|
||||
uploaded_part_sizes: list[int] = []
|
||||
|
||||
async def record_upload_part(**kwargs):
|
||||
body = kwargs.get("Body", b"")
|
||||
uploaded_part_sizes.append(len(body))
|
||||
return {"ETag": f"etag-{len(uploaded_part_sizes)}"}
|
||||
|
||||
mock_client.upload_part.side_effect = record_upload_part
|
||||
|
||||
await agent._upload_multipart("test.tar", open_stream)
|
||||
|
||||
# Verify that all non-trailing parts have the same size
|
||||
assert len(uploaded_part_sizes) >= 2, "Expected at least 2 parts"
|
||||
non_trailing_parts = uploaded_part_sizes[:-1]
|
||||
assert all(size == MULTIPART_MIN_PART_SIZE_BYTES for size in non_trailing_parts), (
|
||||
f"All non-trailing parts should be {MULTIPART_MIN_PART_SIZE_BYTES} bytes, got {non_trailing_parts}"
|
||||
)
|
||||
|
||||
# Verify the trailing part contains the remainder
|
||||
total_data = sum(chunk_sizes) * mib
|
||||
expected_trailing = total_data % MULTIPART_MIN_PART_SIZE_BYTES
|
||||
if expected_trailing == 0:
|
||||
expected_trailing = MULTIPART_MIN_PART_SIZE_BYTES
|
||||
assert uploaded_part_sizes[-1] == expected_trailing
|
||||
|
||||
|
||||
async def test_agents_download(
|
||||
hass_client: ClientSessionGenerator,
|
||||
mock_client: MagicMock,
|
||||
|
||||
@@ -110,6 +110,21 @@ async def test_climate_entities(
|
||||
HVACAction.FAN,
|
||||
id="fan",
|
||||
),
|
||||
pytest.param(
|
||||
_make_climate_data(hvac_state="Idle"),
|
||||
HVACAction.IDLE,
|
||||
id="idle",
|
||||
),
|
||||
pytest.param(
|
||||
_make_climate_data(hvac_state="Stage 1 Heat"),
|
||||
HVACAction.HEATING,
|
||||
id="stage_1_heat",
|
||||
),
|
||||
pytest.param(
|
||||
_make_climate_data(hvac_state="Stage 2 Cool", hvac_mode="Cool"),
|
||||
HVACAction.COOLING,
|
||||
id="stage_2_cool",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user