Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions homeassistant/components/adguard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
CONF_VERIFY_SSL,
Platform,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType

Expand Down Expand Up @@ -120,8 +120,37 @@ async def refresh(call: ServiceCall) -> None:
return True


@callback
def _async_migrate_device_identifiers(
hass: HomeAssistant, entry: AdGuardConfigEntry
) -> None:
"""Migrate devices identified by host, port and base path to the entry ID.

Those identifiers had four parts, while the device registry only supports two.
"""
device_registry = dr.async_get(hass)
identifiers = {(DOMAIN, entry.entry_id)}
migrated = device_registry.async_get_device_by_identifier(
(DOMAIN, entry.entry_id), entry.entry_id
)

for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id):
if device.identifiers == identifiers:
continue

# Downgrading recreates the old device, leaving a duplicate behind. Its
# entities move back to the migrated device when the platforms set up.
if migrated is not None:
device_registry.async_remove_device(device.id)
continue

device_registry.async_update_device(device.id, new_identifiers=identifiers)


async def async_setup_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> bool:
"""Set up AdGuard Home from a config entry."""
_async_migrate_device_identifiers(hass, entry)

session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL])
adguard = AdGuardHome(
entry.data[CONF_HOST],
Expand Down
9 changes: 1 addition & 8 deletions homeassistant/components/adguard/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,7 @@ def device_info(self) -> DeviceInfo:

return DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={
( # type: ignore[arg-type]
DOMAIN,
self.adguard.host,
self.adguard.port,
self.adguard.base_path,
)
},
identifiers={(DOMAIN, self._entry.entry_id)},
manufacturer="AdGuard Team",
name="AdGuard Home",
sw_version=self.data.version,
Expand Down
76 changes: 47 additions & 29 deletions homeassistant/components/backup/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,10 @@
from .util import (
DecryptedBackupStreamer,
EncryptedBackupStreamer,
iter_upload_chunks,
make_backup_dir,
read_backup,
receive_file,
validate_password,
validate_password_stream,
)
Expand Down Expand Up @@ -1004,7 +1006,6 @@ async def _async_receive_backup(
contents: aiohttp.BodyPartReader,
) -> str:
"""Receive and store a backup file from upload."""
contents.chunk_size = BUF_SIZE
suggested_filename = contents.filename or "backup.tar"
safe_filename = PureWindowsPath(suggested_filename).name
if (
Expand All @@ -1022,7 +1023,7 @@ async def _async_receive_backup(
)
written_backup = await self._reader_writer.async_receive_backup(
agent_ids=agent_ids,
stream=contents,
stream=iter_upload_chunks(contents),
suggested_filename=suggested_filename,
)
self.async_on_backup_event(
Expand Down Expand Up @@ -1958,6 +1959,47 @@ def is_excluded_by_filter(path: PurePath) -> bool:
) from err
return (tar_file_path, stat_result.st_size)

async def _receive_and_move_backup(
self,
*,
agent_ids: list[str],
stream: AsyncIterator[bytes],
temp_file: Path,
) -> tuple[AgentBackup, Path]:
"""Receive the upload into temp_file, validate it, and move it into place.

Remove temp_file on any failure, including cancellation from a client
disconnect, so a partial or unparsable upload does not orphan a
potentially large temp file.
"""
async_add_executor_job = self._hass.async_add_executor_job
try:
await receive_file(self._hass, stream, temp_file)
try:
backup = await async_add_executor_job(read_backup, temp_file)
except (
OSError,
tarfile.TarError,
json.JSONDecodeError,
KeyError,
InvalidBackupFilename,
) as err:
LOGGER.warning("Unable to parse backup %s: %s", temp_file, err)
raise

manager = self._hass.data[DATA_MANAGER]
if self._local_agent_id in agent_ids:
local_agent = manager.local_backup_agents[self._local_agent_id]
tar_file_path = local_agent.get_new_backup_path(backup)
await async_add_executor_job(make_backup_dir, tar_file_path.parent)
await async_add_executor_job(shutil.move, temp_file, tar_file_path)
else:
tar_file_path = temp_file
except Exception, asyncio.CancelledError:
await async_add_executor_job(temp_file.unlink, True)
raise
return backup, tar_file_path

@override
async def async_receive_backup(
self,
Expand All @@ -1971,33 +2013,9 @@ async def async_receive_backup(

async_add_executor_job = self._hass.async_add_executor_job
await async_add_executor_job(make_backup_dir, self.temp_backup_dir)
f = await async_add_executor_job(temp_file.open, "wb")
try:
async for chunk in stream:
await async_add_executor_job(f.write, chunk)
finally:
await async_add_executor_job(f.close)

try:
backup = await async_add_executor_job(read_backup, temp_file)
except (
OSError,
tarfile.TarError,
json.JSONDecodeError,
KeyError,
InvalidBackupFilename,
) as err:
LOGGER.warning("Unable to parse backup %s: %s", temp_file, err)
raise

manager = self._hass.data[DATA_MANAGER]
if self._local_agent_id in agent_ids:
local_agent = manager.local_backup_agents[self._local_agent_id]
tar_file_path = local_agent.get_new_backup_path(backup)
await async_add_executor_job(make_backup_dir, tar_file_path.parent)
await async_add_executor_job(shutil.move, temp_file, tar_file_path)
else:
tar_file_path = temp_file
backup, tar_file_path = await self._receive_and_move_backup(
agent_ids=agent_ids, stream=stream, temp_file=temp_file
)

async def send_backup() -> AsyncIterator[bytes]:
f = await async_add_executor_job(tar_file_path.open, "rb")
Expand Down
25 changes: 18 additions & 7 deletions homeassistant/components/backup/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,8 +507,18 @@ def backup(self) -> AgentBackup:
return replace(self._backup, protected=True, size=self.size())


async def iter_upload_chunks(contents: aiohttp.BodyPartReader) -> AsyncIterator[bytes]:
"""Yield chunks of an uploaded file.

Iterating a BodyPartReader reads the whole part into memory and enforces the
request's client_max_size limit; reading it in chunks does neither.
"""
while chunk := await contents.read_chunk(BUF_SIZE):
yield chunk


async def receive_file(
hass: HomeAssistant, contents: aiohttp.BodyPartReader, path: Path
hass: HomeAssistant, stream: AsyncIterator[bytes], path: Path
) -> None:
"""Receive a file from a stream and write it to a file."""
queue: SimpleQueue[tuple[bytes, asyncio.Future[None] | None] | None] = SimpleQueue()
Expand All @@ -526,10 +536,10 @@ def _sync_queue_consumer() -> None:
fut: asyncio.Future[None] | None = None
try:
fut = hass.async_add_executor_job(_sync_queue_consumer)
megabytes_sending = 0
while chunk := await contents.read_chunk(BUF_SIZE):
megabytes_sending += 1
if megabytes_sending % 5 != 0:
chunks_sent = 0
async for chunk in stream:
chunks_sent += 1
if chunks_sent % 5 != 0:
queue.put_nowait((chunk, None))
continue

Expand All @@ -542,8 +552,9 @@ def _sync_queue_consumer() -> None:
if fut.done():
# The executor job failed
break

queue.put_nowait(None) # terminate queue consumer
finally:
# Always terminate the queue consumer, also if the stream raised or the
# task was cancelled.
queue.put_nowait(None)
if fut is not None:
await fut
4 changes: 4 additions & 0 deletions homeassistant/components/cover/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.deprecation import deprecated_function
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
Expand Down Expand Up @@ -91,6 +92,9 @@
]


@deprecated_function(
"hass.states.is_state(entity_id, 'closed')", breaks_in_ha_version="2027.10"
)
def is_closed(hass: HomeAssistant, entity_id: str) -> bool:
"""Return if the cover is closed based on the statemachine."""
return hass.states.is_state(entity_id, CoverState.CLOSED)
Expand Down
52 changes: 31 additions & 21 deletions homeassistant/components/file_upload/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,27 +176,37 @@ def _sync_queue_consumer() -> None:

fut: asyncio.Future[None] | None = None
try:
fut = hass.async_add_executor_job(_sync_queue_consumer)
megabytes_sending = 0
while chunk := await file_field_reader.read_chunk(ONE_MEGABYTE):
megabytes_sending += 1
if megabytes_sending % 5 != 0:
queue.put_nowait((chunk, None))
continue

chunk_future = hass.loop.create_future()
queue.put_nowait((chunk, chunk_future))
await asyncio.wait(
(fut, chunk_future), return_when=asyncio.FIRST_COMPLETED
)
if fut.done():
# The executor job failed
break

queue.put_nowait(None) # terminate queue consumer
finally:
if fut is not None:
await fut
try:
fut = hass.async_add_executor_job(_sync_queue_consumer)
chunks_sent = 0
while chunk := await file_field_reader.read_chunk(ONE_MEGABYTE):
chunks_sent += 1
if chunks_sent % 5 != 0:
queue.put_nowait((chunk, None))
continue

chunk_future = hass.loop.create_future()
queue.put_nowait((chunk, chunk_future))
await asyncio.wait(
(fut, chunk_future), return_when=asyncio.FIRST_COMPLETED
)
if fut.done():
# The executor job failed
break
finally:
# Always terminate the queue consumer, also if the stream raised or
# the task was cancelled.
queue.put_nowait(None)
if fut is not None:
await fut
except Exception, asyncio.CancelledError:
# Upload failed: the consumer has finished and closed the file (inner
# finally above), so removing the directory now cannot race the writer.
# ignore_errors covers a failure that happened before the dir was created.
await hass.async_add_executor_job(
lambda: shutil.rmtree(file_dir, ignore_errors=True)
)
raise

file_upload_data.files[file_id] = filename

Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/frontend/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260826.2"]
"requirements": ["home-assistant-frontend==20260826.4"]
}
28 changes: 28 additions & 0 deletions homeassistant/components/midea/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@
CONF_TYPE,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo

from .const import (
CONF_ACCOUNT,
Expand Down Expand Up @@ -772,3 +774,29 @@ def _show_manually_form(
data_schema=schema,
errors={"base": error} if error else None,
)

@override
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery of a known Midea device.

Only devices already configured (matched via ``registered_devices``)
reach this step. It is used to keep the stored host in sync with the
current IP address of the device.
"""
mac = format_mac(discovery_info.macaddress)
for entry in self._async_current_entries():
if (entry_mac := entry.data.get(CONF_MAC)) is None or format_mac(
entry_mac
) != mac:
continue
if entry.data[CONF_IP_ADDRESS] != discovery_info.ip:
self.hass.config_entries.async_update_entry(
entry,
data=entry.data | {CONF_IP_ADDRESS: discovery_info.ip},
)
self.hass.config_entries.async_schedule_reload(entry.entry_id)
return self.async_abort(reason="already_configured")

return self.async_abort(reason="no_devices_found")
5 changes: 5 additions & 0 deletions homeassistant/components/midea/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
"name": "Midea",
"codeowners": ["@chemelli74", "@rokam", "@caibinqing"],
"config_flow": true,
"dhcp": [
{
"registered_devices": true
}
],
"documentation": "https://www.home-assistant.io/integrations/midea",
"integration_type": "device",
"iot_class": "local_polling",
Expand Down
Loading
Loading